Docs
Advanced
Customizing the protocol

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.

ProtocolStateModule
export class ProtocolStateModule extends ProvableTransactionHook {
  @protocolState() public accountState = StateMap.from<PublicKey, AccountState>(
    PublicKey,
    AccountState
  );
 
  public onTransaction({ transaction }: BlockProverExecutionData): void {
    const accountState = this.accountState
      .get(transaction.sender)
 
    this.accountState.set(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:

TransactionFeeModule
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 fired
  • networkState: The network state that was valid during the transaction's execution
  • runtimeProof: The proof of the transaction's runtime execution
  • stateTransitionProof: The proof of the transaction's state transitions
  • transactionPosition: Indicates whether the transaction was the FIRST or LAST transaction of the block, or if it was somewhere in the MIDDLE

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.