Connecting the EVM PA Controller to the Anoma Protocol

This post aims to illuminate how we can connect the EVM protocol adapter controller to the Anoma protocol, i.e., ADOS v0.3. As requested by the engineering team (@mariari, @artemg, @cdetroye) will put a special focus on how the protocol adapter state and events can be made available in the ADOS and what software components are needed. Additionally, I will try to map these components to engines/system mentioned in the specs (at least to my rough understanding). To improve the latter mapping, I am happy for corrections and improvements by the specs team.

The EVM Controller

The EVM protocol adapter (PA) is a contract written in Solidity that can be deployed on any EVM compatible chain. Currently, it is deployed on sepolia, the Ethereum testnet.

The PA effectively implements a virtual machine (VM), i.e., the Anoma Resource Machine (ARM) running inside another VM, the Ethereum Virtual Machine (EVM).
Piggybacking on the EVM and the Ethereum protocol, the following components are already provided:

  • Ordering
  • Execution (including gas metering)
  • Consensus (including networking and transaction mempool)
  • Identities

The PA contract being part of the Ethereum state can verify and execute RM transactions and implements a

  • Commitment accumulator
  • Nullifier set
  • Rudimentary blob storage with two deletion criteria (Never and Immediately)

This makes the PA contract on Ethereum a resource controller and enables a simplified version of Anoma protocol with

  • Limited execution engine behaviour
    • The PA is a settlement-only, i.e., it is only capable of processing fully-evaluated transaction functions and therefore does not implement the full executor engine behaviour.
  • Limited storage/shard engine behaviour
    • The PA stores ARM-related state (i.e., commitments, roots, nullifiers, blobs) directly on Ethereum inside its contract state and emits events as part of the Ethereum event logs.
    • The PA doesn’t implement a general key-value store or locking mechanisms for concurrent reads/writes since reads and writes happen as part of the EVM execution trace.

Abstracting the EVM PA Controller

The engineering team has been working on implementing the Anoma distributed operating sysyem (ADOS). The goal is now to connect to the Ethereum PA controller to ADOS v0.3 and abstract it’s details away behind the ADOS components, which, IMO, should initially be greatly simplified versions of the subsystems, engines, etc. being described in the specs.

In ADOS, the EVM PA controller state must become available in the Anoma local domain as a continuous stream of newly added

  • Commitments (and related Roots)
  • Nullifiers
  • Blobs

This could be abstracted in the form of a of simplified storage engine interfacing with a simple indexing service providing access to the above data.

Besides continuously synchronizing with the EVM PA controller state, the local domain can obtain the following data directly from the PA contract in an on-demand read call:

  • The latest root
  • Merkle proofs for commitments (which depend on the underlying Merkle tree implementation)
  • Stored blobs

Note, that the above read methods have been implemented for convenience. The data could also be obtained or computed from the events data from above.

Besides read calls, the Anoma local domain must make an on-demand write call to the PA to execute a RM transaction. This could be abstracted in the form of a simplified executor engine.

Moreover, since write calls cost gas, the Anoma local domain has to support a limited version of the identity architecture/engine making Ethereum identities available in Anoma apps to sign/verify and encrypt/decrypt data and interfacing with existing Ethereum wallet solutions (i.e., browser extension, hardware wallets, etc.).

Overall, this would allow ADOS to abstract the EVM PA controller away.

The EVM Protocol Adapter Solidity Interface

To facilitate the connection with the Anoma local domain and its components, the EVM PA contract exposes the following Solidity interface:

interface IProtocolAdapter {
    function execute(Transaction calldata transaction) external;
    function verify(Transaction calldata transaction) external view;
}
interface INullifierSet {
    event NullifierAdded(bytes32 indexed nullifier, uint256 indexed index);
}
interface ICommitmentAccumulator {
    event CommitmentAdded(bytes32 indexed commitment, uint256 indexed index);
    event RootAdded(bytes32 indexed root);

    function latestRoot() external view returns (bytes32 root);
    function containsRoot(bytes32 root) external view returns (bool isContained);
    function merkleProof(bytes32 commitment) external view returns (bytes32[] memory siblings, uint256 directionBits);
    function verifyMerkleProof(bytes32 root, bytes32 commitment, bytes32[] calldata siblings, uint256 directionBits) external view;
}
interface IBlobStorage {
    event BlobStored(bytes32 indexed blobHash, DeletionCriterion indexed deletionCriterion);

    function lookupBlob(bytes32 blobHash) external view returns (bytes memory blob);
}

Every time a transaction is executed on the EVM PA contract, the NullifierAdded,CommitmentAdded , RootAdded, and BlobStored events are emitted, accordingly. This facilitates the continuous synchronization with the local domain. All other methods can be permissionlessly called.

To better understand the Solidity interface, read the Solidity recap below.

Brief Solidity Recap (optional)

Solidity defines external (or public) facing functions that accounts (wallets or other contracts) can call (in contrast to internal functions).

We must further distinguish between

  • write functions (that can emit events)
  • read functions (as indicated by the view keyword)

Write (State-changing) Functions

Purpose:

These are regular functions that modify the blockchain state and require a transaction.

How They Work:
  • Called by submitting a transaction.
  • Requires gas and is included in a block.
  • Example:
    function execute(Transaction tx) external { /* modifies state */ }
Characteristics:
  • Involves gas cost and mining time.
  • Can trigger events/logs.
  • Transaction is permanently recorded on the blockchain.
  • Can fail (revert), in which case no state change occurs and gas is still partially consumed.
When to Use:
  • For any action that changes state (e.g., token transfers, minting, updating storage).

Read (Non-state-changing) Functions

Purpose:

Functions marked as

  • view read data and process data from the blockchain without modifying the state.
  • pure just process the input arguments and do not even read from Ethereum state.
How They Work:
  • They do not create a transaction.
  • Executed locally (not mined, not broadcast to the network).
  • Examples:
    function latestRoot() external view returns (bytes32 root) { /* reads state and returns it */ }
    function merkleProof(bytes32 commitment) external view returns (bytes32[] memory siblings, uint256 directionBits) { /* reads state and computes results that get returned */ }
Characteristics:
  • No gas cost (unless called inside another transaction).
  • No state changes.
  • Not recorded on-chain — there’s no trace in the blockchain that they were ever called. Exception: If the are called from within state changing function (e.g., `transferI, the code is effectively inlined and costs gas.
When to Use:
  • To read contract state (e.g., balances, ownership).
  • For front-end/UI queries.

Events (Logs)

Events are used by smart contracts to emit logs, which are stored in the blockchain’s log data (but not in contract state).

How They Work:
  • Emitted using Solidity’s emit keyword as part of a write function call.
  • Example:
    event NullifierAdded(bytes32 indexed nullifier, uint256 indexed index) which is emitted when nullifiers are added to the nullifier set as part of the RM transaction execution
Characteristics:
  • Events are not accessible from within smart contracts.
  • They are not part of the EVM state, but are stored in a block’s log bloom filter.
  • Indexed fields (indexed) go into topics for efficient filtering.
  • Efficient for off-chain listening (used by indexers and apps).
When to Use:
  • For notifications (e.g., transfers, changes, approvals).
  • For off-chain apps and indexing systems.

Summary

Feature Write Function Calls Read Function Calls Events (Logs)
Blockchain Write? :white_check_mark: Full state change :cross_mark: No :white_check_mark: Logs only
Costs Gas? :white_check_mark: Yes :cross_mark: No :white_check_mark: (included in tx)
Can Emit Events? :white_check_mark: Yes :cross_mark: No -
Can Change State? :white_check_mark: Yes :cross_mark: No :cross_mark: No
Visible On-chain? :white_check_mark: Yes :cross_mark: No (off-chain only) :white_check_mark: In logs
Called How? eth_sendTransaction from user/client Local eth_call Emitted as part of a write function call
Use Case Change state Read data/view state Notify watchers

Continuous Indexing of PA Events

To mirror/synchronize with the EVM PA controller state, the local domain has to continuously listen to the PA contract events and store the data in a database (potentially being part or abstracted by a storage/shard engine).
This allows for custom database queries, is lower latency, and avoids size constraints by the EVM and RPC protocol that could become a problem for large data sets.

Syncing and listening to Ethereum events requires running a synchronized Ethereum archive node that maintains historical Ethereum state and allows a connection via JSON-RPC. A full node would only allow for getting event logs of the latest 10064 blocks (see the Reth book for more details).
Listening for events in real-time requires a bidirectional connection via WebSocket (WS) (see the Reth book for more details). This is not possible via https which only supports request-response calls.

Given an RPC connection to the archive node, and indexer process can sync all the historical events that have happened since the PA contract has been deployed by (repeatedly) calling the eth_getLogs JSON-RPC method with the following input parameters:

  • the PA contract address (address)
  • the block at which the PA contract was deployed (fromBlock) and a later block (toBlock, e.g., the latest block)
  • the event topics (topics) of the NullifierAdded,CommitmentAdded, RootAdded, and BlobStored events

The archive node will then go through the transaction receipt event logs, filter them by topic, and return the data. On a self-hosted node, there are no size constraints on the maximum number of logs that can be returned. However, since the data has to fit in memory, it can make sense to pull the data in batches.
The events data can then be processed and put into a database (e.g. PostgresSQL).

Once the indexer has synced old history, it can start listening to new events in real time using the eth_subscribe JSON-RPC method with the subscription type logs and put them into the database as well.

The storage/shard engine could connect to the indexing service and the database and make the data available in the ADOS local domain, e.g., to apps wanting an up-to-date list of all commitments of unspent resources.

The detailed relationships and responsibilities of the local domain, simplified storage/shard engine, and the indexer (which can be thought of as a node committing to providing a service to other nodes) should be subject to discussion in the upcoming HHH team meeting.

On-demand Read Calls

As mentioned before, transaction functions of apps connected to the local domain require

  • the latest root
  • Merkle proofs for commitments
  • stored blobs

to prepare the transaction.

The latest root can be obtained in two ways: Provided a connection to the indexing service, the local domain (or, more precisely, the storage engine interfacing with it) can obtain the latest root from the latest emitted RootAdded event.
Alternatively, it could directly obtain it from the protocol adapter contract with the eth_call JSON-RPC method without gas costs.

Similarly, the Merkle proof for a given commitment can be obtained in two ways.
Given the synchronized history of commitments and using the same Merkle tree implementation as used in the PA commitment accumulator implementation, the root could can be recomputed.
The better alternative is to directly call the merkleProof PA method, which stores and utilizes intermediary Merkle tree nodes. This is convenient and avoids indexing services becoming a central point of failure.
It could be worth considering to remove this feature and to instead use a more gas efficient Merkle tree implementation. This would require some benchmarking on my side.

The stored blobs, however, can not be indexed in the current PA design an must be obtain from in a read call to the lookupBlob method. This is because they are directly stored as part of the PA contract state and to support different deletion criteria. This design could be revisited to support data availability solutions.

In general, we should reconsider the current storage design thoughtfully under different aspects, such as decentralization, gas costs, as well as engineering complexity.
In this context, we should also explore how we want data related to PA forwarder calls interacting with external Ethereum state to be handled and what the interface and abstraction for applications should be in this case.

On-demand Write Calls

Once the transaction function has been fully evaluated, the resulting transaction object can be converted into EVM bytes (see the related Juvix feature), signed by the identity engine and an RPC connection using the eth_signTransaction method, and sent with the eth_sendRawTransaction method to the PA contract’s execute(Transaction tx) function. This could be done in a simplified executor engine.

2 Likes