Customizing the protocol
Protokit allows for the customization of the underlying protocol. There are multiple ways to do that, we will focus on the easiest and high-levelest approach: Hooks.
Hooks
Hooks allow you plug into the protocol and execute certain tasks in pre-defined points in the protocol.
Currently, there exist two types of Hooks:
TransactionHooks
BlockHooks
All Hooks have the important property that they will always be executed, meaning their result doesn't depend on the success of the underlying runtime execution.
For example, if you want to implement a hook that implements transaction fees, you want to deduct the fees even though the runtime execution reverts. This is exactly how protocol hooks work, they execute always.
In order for your appchain to work properly, make sure that your protocol hooks never fail forcefully and handle all edge cases.
Unmet o1js assertions like a.assertEquals(b)
will lead to your appchain not working properly!
Usage
In order to use a hook inside your protocol, you simply provide it as a module in your Protocol.
new TestingAppChain({
runtime,
sequencer,
protocol: VanillaProtocol.from({
TransactionFeeModule
}),
Working with custom state
Protocol hooks can define their own state and use that to read and modify the appchain state.
They work similar to the runtime @state()
state and emit StateTransitions under the hood.
To define state in ProtocolModules, you will have to use the @protocolState()
decorator instead of @state()
.
The rest, like the State
and StateMap
classes, stays the same.
export class ProtocolStateModule extends ProvableTransactionHook {
@protocolState() public accountState = StateMap.from(PublicKey, AccountState);
public onTransaction(): void {
const accountState = this.accountState.get(this.transaction.sender.value)
this.accountState.set(this.transaction.sender, someAccountState);
...
}
}
Calling runtime modules
Because of how protokit is architected behind the scenes, ProtocolModules
can actually call and use RuntimeModules
.
Let's assume we have a runtime module called Balances
that keeps track of token balances and for every transaction, we want to deduct 100 tokens as a transaction fee.
This can be implemented very easily:
export class SimpleTransactionFeeModule extends ProvableTransactionHook {
private balances: Balances;
public constructor(
@inject("Runtime") runtime: Runtime<RuntimeModulesRecord>
) {
super();
this.balances = runtime.resolveOrFail("Balances", Balances);
}
public onTransaction({ transaction }: BlockProverExecutionData): void {
this.balances.transfer(
transaction.sender,
PublicKey.empty(),
UInt64.from(100)
);
}
}
RuntimeModules that are called this was, will be treated as protocol-executed code by the framework. That means that these runtime calls will be executed even if the underlying transaction fails and they cannot use
assert
.
TransactionHooks
Transaction Hooks are classes that extend ProvableTransactionHook
.
This class forces you to implement
public onTransaction({ transaction }: BlockProverExecutionData): void
The provided argument BlockProverExecutionData
contains:
transaction
: The transaction on which this hook was firednetworkState
: The network state that was valid during the transaction's executionruntimeProof
: The proof of the transaction's runtime executionstateTransitionProof
: The proof of the transaction's state transitionstransactionPosition
: Indicates whether the transaction was theFIRST
orLAST
transaction of the block, or if it was somewhere in theMIDDLE
Given these possibilities, you can actually consume the result of the runtime execution in your transaction hook.
BlockHooks
The purpose of block hooks is to mutate and transform the global NetworkState. NetworkState is data that is immutable by transactions and is guaranteed to be the same for all transactions in any given block.
Between blocks, the BlockHooks are responsible to take the old network state and create the next one that will be provided to all transactions of the coming block. Examples of that could be incrementing the block height, providing the last block's state root and others.
Block Hooks extend the ProvableBlockHook
class and offer two methods:
public beforeBlock(blockData: BeforeBlockParameters): NetworkState
public afterBlock(blockData: AfterBlockParameters): NetworkState
The provided arguments BeforeBlockParameters
and AfterBlockParameters
both hold:
state
: BlockProverState;networkState
: The current active networkState, that should be used for transforming
Lifecycle
Block hooks have two methods that are execute at different points in the block lifecycle.
afterBlock
will be executed after a block is finished.
beforeBlock
builds on top of the result provided by the afterBlock
hook and is execute right before the next block is processed.