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:
TransactionHooksBlockHooks
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. Both transaction and block hooks work very similar so this section is true for both.
new TestingAppChain({
runtime,
sequencer,
protocol: VanillaProtocolModules.with({
TransactionFeeModule
}),Working with custom state
Protocol hooks can define their own state and use that to read and modify the appchain state.
They work like the runtime @state() state and emit StateTransitions under the hood.
export class ProtocolStateModule extends ProvableTransactionHook<Config> {
@state() public accountState = StateMap.from(PublicKey, AccountState);
public async beforeTransaction(
executionData: BeforeTransactionHookArguments
): Promise<void> {
const accountState = this.accountState.get(this.transaction.sender.value)
await this.accountState.set(this.transaction.sender.value, 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 afterTransaction({ 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 beforeTransaction({ transaction }: BlockProverExecutionData): Promise<void>public afterTransaction({ transaction }: BlockProverExecutionData): Promise<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 theFIRSTorLASTtransaction 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.
Block building implications
When building blocks, protokit will execute transactions in the order that’s given by the mempool (and it’s sorting module).
During execution, block hooks may fail assertions and therefore will not be included in the block. The block builder, however, needs to figure out whether to throw away the transaction or keep it in the mempool for future blocks.
An example of a case where the transaction should stay in the mempool is if the nonce is too big for the current state. The transaction would fail if included at that point, but if transactions arrive, increasing the nonce gradually, eventually that transaction would become valid again.
To model this behaviour, transaction hooks can implement
public removeTransactionWhen(execution: BeforeTransactionHookArguments): Promise<boolean>
The contract of this method is as follows:
If removeTransactionWhen returns true, the transaction will be dropped from the mempool after previous unsuccessful execution of the transaction hooks.
If it returns false, then it will remain in the mempool inactive, until any of the input state to the hook’s execution changes, at which point it will be added back into the mempool.
Note that removeTransactionWhen does not have to be provable, therefore you can use arbitrary javascript logic inside this function
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(networkState: NetworkState, state: BeforeBlockHookArguments): Promise<NetworkState>public afterBlock(networkState: NetworkState, state: AfterBlockHookArguments): Promise<NetworkState>
The provided arguments hold:
networkState: The current active networkState, that should be used for transformingstate: BlockProverState;
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 executed right before the next block is processed.