Some Notes On The Solana Protocol Adapter Prototype

I recently started implementing the Solana Protocol Adapter, and this post is meant to summarize the core design decisions and considerations. This will, hopefully, be read by @ArtemG in particular and anyone else that needs catching up on its design.

You can find the current version here.

Goals and Solana

What is a Protocol Adapter ( PA ), anyway? The Anoma Resource Machine (RM) has an implementation which runs on Risc0. At a high level, this can be used to calculate transactions which can be executed to update the state of the RM as well as verify correctness. The PA represents this state on chain, takes a transaction from the RM, verifies the proof verifying the correctness of the transaction, calls out to external contracts, and updates the state, mostly meaning creating nullifiers for consumed resources and updating the commitment tree. The PA is a concrete instantiation of the state of the RM on chain.

There already exists an audited protocol adapter for the EVM. This illustrates what an acceptable design looks like and was the basis for the Solana version. Additionally, many of the core technical issues were already solved from the start. arm-risc0 is written in Rust, the lingua franca of Solana contracts. It also uses Groth16; which also has its own deployed and audited implementation on Solana. The pieces already exist and just needed to be put together.

The main library for developing Solana applications is a mixed rust/typescript library called Anchor which automatically handles a lot of things related to account access, deserialization, etc for us.

One of the largest differences between EVM and Solana is the execution model. The EVM is quite permissive in terms of execution. Contracts can call each other in arbitrary ways, with the main limitation being gas. On Solana, all data and programs are stored in blobs called β€œaccounts”. Solana transactions must list all accounts which they expect to make contact with up front. All programs on Solana are stateless, and contained in an account as its data payload. This means that, what passes for state, must be provided as the input to the Solana program. This is done by storing state in accounts and passing those accounts into the transaction. This ended up not having a huge effect on the overall PA design, but it may be useful to keep this in mind, as it was something that worried me early on.

One way this difference manifests is in the call structure of forwarders. In the EVM block time forwarder example we have something like

function forwardCall(bytes32 logicRef, bytes calldata input) external returns (bytes memory) {
    ...
    uint48 currentTime = Time.timestamp();
    ...
    output = abi.encode(result);
}

in Solana, it’s quite similar but we have an extra argument

pub fn forward_call(ctx: Context<ForwardCall>, logic_ref: [u8; 32], input: Vec<u8>) -> Result<()> {
    ...
    let clock = &ctx.accounts.clock; // Clock present in Context
    ...
    set_return_data(&[result]);
    Ok(())
}

passing the context, which contains all the accounts our transaction uses up front. In Solana, there’s a standard clock account, SysvarC1ock11111111111111111111111111111111, which would have to be passed as an argument if one wants the timestamp. This clearly affects the design of forwarders themselves, but the PA doesn’t, itself, change much from this. We do need to structure things to accept and pass the context around as needed, but it’s not a severe complication.

To my knowledge, this is mostly for parellelization. Knowing the full states used as inputs from the get go allows the scheduler to plan concurrent executions. I don’t really know the details, though. It also prevents some reentrancy issues. The EVM ProtocolAdapter inherits from ReentrancyGuardTransient to prevent attacks. Most of these attacks are prevented by the Solana set up, but not all; specifically self-reentry is still possible.

Solana uses what they call β€œCross Program Invocation” (CPI) to get contracts to talk to eachother. The CPI allows one program, on one account, to issue arguments to another program on another account. These CPI calls have the same (or a subset of) the context of the initial transaction, so we can’t store addresses we want to access in a transaction within the data of an account. The CPI calls do modify state; if we have an account that reads from X use the CPI to call a program that modifies X, it will see the modified state of X after the invocation. There is a max depth of 4 to these calls.

If it were the case that forwarders were arbitrary contracts, then this would severely hamper the possibility of porting since we won’t be able to know, up front, what accounts that forwarder will, itself, want to access. However, forwarders aren’t arbitrary accounts; they’re purpose built to interface with RM transactions. As such, we can push this problem onto the forwarders themselves instead of trying to solve this generically.

Constraints and Solutions

The more significant constraints ended up being compute related. Most significantly

Constraint Value Impact
Transaction size 1232 bytes max Cannot fit full RM tx + proofs in single instruction
Stack frame 4KiB max Large structs crash when deserialized
Compute budget 200k per instruction, 1.4M max per tx Verification + merkle + CPIs must fit
CPI depth 4 levels max PA β†’ Forwarder β†’ Token Program fits (3 levels)
Return data 1024 bytes, last-write-wins Must read immediately after each CPI
Account Limit 64-128 per tx Limits nullifiers and roots per settlement

Transaction Sizes and Uploading

By far the most significant constraint was the transaction size. Early on, I profiled the size of serialized RM proofs to see if fitting them was a serious issue. The only transaction I found which fit in the 1232 byte Solana transaction limit was an aggrigated proof with only 1 compliance unit. It was 870 bytes. Adding one more compliance unit added 459 bytes of additional data, pushing it over the edge. What this means is we can’t just send an RM transaction over a single Solana transaction, we have to spread it out.

Solana has two kinds of accounts; accounts with private keys and accounts without private keys that are owned by accounts with them. These latter accounts are called Program Derived Addresses (PDAs) and each is a unique address derived from a combination of seed data and an account which acts as the owner of the PDA. Only the owning account can modify the PDA. All the account data structures are actually stored in PDA accounts which are separate from the main contract account actually running the PA itself.

One of these accounts is a TxData account which contains the mechanism for uploading transactions. It’s defined as

pub struct TxDataAccount {
    /// Bump seed for PDA derivation.
    pub bump: u8,

    /// Authority that can write.
    pub authority: Pubkey,

    /// Account to receive rent refund on close.
    pub refund: Pubkey,

    /// Bytes written so far.
    pub written_len: u32,

    /// Slot when this account expires.
    pub expires_slot: u64,

    /// Payload data (variable length, allocated at creation).
    pub payload: Vec<u8>,
}

The actual transaction is stored, serialized, in payload. An important technical detail is that, during Anchor deserialization when data from this account is read to get the actual transaction, the main data of payload is stored on the heap. Everything else, including the length, capacity, and a pointer to the heap for the payload is placed in a single stack frame. At least, that’s my current understanding.

The principle way communication happens on Solana is through RPC calls. The PA contract account receives these calls as part of input and executes them. For the upload mechanism, the contract recieves parts of the upload and manipulates the account storing the TxDataAccount. We have three main commands

/// Initialize a TxData account for chunked upload.
///
/// # Arguments
/// * `upload_id` - Unique identifier for this upload (client-provided, e.g. timestamp)
/// * `capacity` - Size of payload buffer to allocate
/// * `expires_slot` - Slot after which this TxData expires
pub fn txdata_init(
    ctx: Context<TxDataInit>,
    upload_id: u64,
    capacity: u32,
    expires_slot: u64,
) -> Result<()>
...

pub fn txdata_write(
        ctx: Context<TxDataWrite>,
        upload_id: u64,
        offset: u32,
        data: Vec<u8>,
) -> Result<()>
...

pub fn settle_from_txdata<'a, 'b, 'c, 'info>(
        ctx: Context<'a, 'b, 'c, 'info, SettleFromTxData<'info>>,
        upload_id: u64,
) -> Result<()>
...

These are sent by a client as RPC calls to the contract in typescript;

await program.methods
      .txdataInit(uploadId, capacity, expiresSlot)
      .accounts({
        txData,
        authority: authority.publicKey,
        systemProgram: SystemProgram.programId,
      })
      .signers([authority])
      .rpc();
...

[for each chunk]
await program.methods
      .txdataWrite(uploadId, offset, Buffer.from(chunk))
      .accounts({
        txData,
        authority: authority.publicKey,
      })
      .signers([authority])
      .rpc();
...
return program.methods
      .settleFromTxdata(uploadId)
      .accounts({
        paState,
        txData,
        authority: authority.publicKey,
        systemProgram: SystemProgram.programId,
        verifierRouterProgram: verifierRouterId,
        router: routerPda,
        verifierEntry: verifierEntryPda,
        verifierProgram: groth16VerifierId,
      })
      .remainingAccounts(allRemainingAccounts)
      .signers([authority])
      .rpc();

With flow (Assuming 900 byte chunk sizes)

Client                                      Solana
   |                                           |
   |-- CreateAccount (TxData) ---------------->|
   |-- txdata_write(offset=0, chunk0) -------->|
   |-- txdata_write(offset=900, chunk1) ------>|
   |   ...                                     |
   |-- settle_from_txdata -------------------->|

That settle command requires a bunch of additional context required for execution of transactions, such as the SystemProgram.programId, required to make new accounts (for example during nullifier creation) and several things related to the groth16 verification.

The router is risc0-solana's verifier dispatch system. It lets you verify proofs without hardcoding which verifier to call.
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                      RISC0-SOLANA VERIFICATION                          β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                                         β”‚
β”‚   Your PA Program                                                       β”‚
β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                                                       β”‚
β”‚   β”‚ settle()    β”‚                                                       β”‚
β”‚   β”‚             β”‚                                                       β”‚
β”‚   β”‚  "verify    β”‚                                                       β”‚
β”‚   β”‚   this      β”‚                                                       β”‚
β”‚   β”‚   proof"    β”‚                                                       β”‚
β”‚   β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜                                                       β”‚
β”‚          β”‚                                                              β”‚
β”‚          β”‚ CPI                                                          β”‚
β”‚          β–Ό                                                              β”‚
β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”          β”‚
β”‚   β”‚              verifierRouterProgram                       β”‚          β”‚
β”‚   β”‚                                                          β”‚          β”‚
β”‚   β”‚   "I dispatch proofs to the right verifier"             β”‚          β”‚
β”‚   β”‚                                                          β”‚          β”‚
β”‚   β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”            β”‚          β”‚
β”‚   β”‚   β”‚           router (PDA)                  β”‚            β”‚          β”‚
β”‚   β”‚   β”‚                                         β”‚            β”‚          β”‚
β”‚   β”‚   β”‚  Registry of verifier entries:          β”‚            β”‚          β”‚
β”‚   β”‚   β”‚  - selector 0 β†’ groth16 verifier       β”‚            β”‚          β”‚
β”‚   β”‚   β”‚  - selector 1 β†’ stark verifier         β”‚            β”‚          β”‚
β”‚   β”‚   β”‚  - selector 2 β†’ ...                    β”‚            β”‚          β”‚
β”‚   β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜            β”‚          β”‚
β”‚   β”‚                      β”‚                                   β”‚          β”‚
β”‚   β”‚                      β”‚ lookup by selector                β”‚          β”‚
β”‚   β”‚                      β–Ό                                   β”‚          β”‚
β”‚   β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”            β”‚          β”‚
β”‚   β”‚   β”‚       verifierEntry (PDA)               β”‚            β”‚          β”‚
β”‚   β”‚   β”‚                                         β”‚            β”‚          β”‚
β”‚   β”‚   β”‚  "For selector=0, call this program:"   β”‚            β”‚          β”‚
β”‚   β”‚   β”‚   β†’ groth16VerifierId                   β”‚            β”‚          β”‚
β”‚   β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜            β”‚          β”‚
β”‚   β”‚                      β”‚                                   β”‚          β”‚
β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜          β”‚
β”‚                          β”‚ CPI                                          β”‚
β”‚                          β–Ό                                              β”‚
β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”          β”‚
β”‚   β”‚              verifierProgram (groth16)                   β”‚          β”‚
β”‚   β”‚                                                          β”‚          β”‚
β”‚   β”‚   "I verify Groth16 proofs"                             β”‚          β”‚
β”‚   β”‚                                                          β”‚          β”‚
β”‚   β”‚   Input: seal, image_id, journal_digest                 β”‚          β”‚
β”‚   β”‚   Output: success or failure                            β”‚          β”‚
β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜          β”‚
β”‚                                                                         β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Why not just call groth16 verifier directly?

  1. Upgradability: Router can point to new verifier versions without changing PA code
  2. Multiple proof types: Same interface for groth16, stark, future proof systems
  3. Selector-based dispatch: Proof includes a selector byte indicating which verifier to use

The accounts:

Account What it is
verifierRouterProgram The router’s program ID - you CPI to this
router Router’s state PDA - stores the registry of verifiers
verifierEntry Entry for this proof type - maps selector β†’ verifier program
verifierProgram The actual verifier that checks the cryptography

The call chain:

PA.settle()
    β”‚
    └──► verifier_router::verify(seal, image_id, journal_digest)
              β”‚
              β”œβ”€β”€ read router PDA β†’ find entry for this selector
              β”‚
              └──► groth16_verifier::verify(...)
                        β”‚
                        └── cryptographic verification
                              β”‚
                              └── return success/failure

The PA doesn’t know or care which verifier runs - it just says β€œverify this proof” and the router figures out the rest

The actual logic that settlement follows what the EVM PA does fairly closely. What’s relevant for Solana I’ll talk about in a later section. After its on chain, the transaction data is sent between accounts on the CPI, which doesn’t have such limits.

Stack Frames and Set Storage

The stack frame limit is specifically relevant for the PAStateAccount structure, which defines encoding and decoding of the contract state which is directly put on chain. If we naively stored everything in it, then reading from the account would trigger a Stack offset ... exceeded max offset ... crash at runtime. This stems from Anchor attempting to deserialize the whole state which, by default, shoves the whole structure onto the stack. Accounts can get much larger than the stack limit, so there are methods that can sidestep this limit, such as Box<Account<...>> which deserializes using the heap instead. The main problematic structure is the nullifier set; the natural solution here is to simply store it separate from the main PA account (which was what I planned before even discovering the stack limit, honestly). We could create a dedicated account just for some set structure, but Solana itself presented a more natural solution that distributes the set across multiple accounts. We could also store the sets as a Vec, similar to the payload of the TxData account, but that’s dumb.

The Protocol Adapter needs to maintain two sets with O(1) membership checks: nullifiers (has this resource been consumed?) and commitment tree roots (is this a valid historical state?) The EVM PA uses OpenZeppelin’s EnumerableSet.Bytes32Set for both:

EnumerableSet.Bytes32Set internal _nullifierSet;
EnumerableSet.Bytes32Set internal _roots;

Solana has no equivalent primitive, but you can store the set as the existence of PDAs. I mentioned before that PDA addresses are derived from seed data along with the controlling account address. This data can be anything. In particular it could be the string nullifier + the nullifier key. This will create a cryptographically unique address for an account which we can open with zero data.

pub fn derive_nullifier_pda(
    program_id: &Pubkey,
    pa_state: &Pubkey,
    nullifier_bytes: &[u8; 32],
) -> (Pubkey, u8) {
    Pubkey::find_program_address(
        &[b"nullifier", pa_state.as_ref(), nullifier_bytes],
        program_id,
    )
}

The PDA’s existence is the membership indicator. Whenever we need to verify the existence of a nullifier, we simply re-derive the address and check if the account already exists. This can be done with

pub fn is_nullifier_spent(program_id: &Pubkey, marker: &AccountInfo) -> bool {
    marker.owner == program_id
}

Note that the owner of an address that hasn’t yet been created is the previously mentioned System Program, 11111111111111111111111111111111. If the owner is, instead, our PA contract, then it has been declared. This is an O(1) check, to my knowledge.

Zero-data accounts are not free. Each marker requires lamports to become rent-exempt. It needs approximately 0.00089 SOL per marker (890,880 lamports for a zero-byte account, covering the 128-byte account metadata overhead). This means, for example, 1000 transactions with 1000 nullifiers will cost roughly 0.89 SOL in rent deposits.

The alternative would be a single account with a complex data structure, paying rent proportional to the data size but saving on per-account overhead.

What remains in the PAStateAccount is some bookkeeping and the whole frontier merkle tree implementation which follows closely to the EVM PA design.

pub struct PAStateAccount {
    /// Bump seed for PDA derivation.
    pub bump: u8,

    /// Authority that can call emergency_stop.
    pub authority: Pubkey,

    /// Whether the protocol is paused (one-way, requires upgrade to unpause).
    pub paused: bool,

    /// Current tree root (computed from frontier).
    /// Stored for quick access without recomputation.
    pub root: [u8; 32],

    /// Next leaf index in the commitment tree.
    pub next_index: u64,

    /// Commitment tree frontier (filled subtree hashes at each level).
    /// Length = TREE_DEPTH (32). Each element is a 32-byte digest.
    pub frontier: [[u8; 32]; TREE_DEPTH],
}

This is, in fact, the only other structured account in the system. All the others are zero-data accounts.

Cryptographic Stuff

The cryptography between the two systems is mostly the same. The biggest component, the Groth16 verification, is already made. That just leaves a handful of differences.

Hash Functions: SHA-256 vs Keccak-256

The EVM uses Keccak-256 for essentially any operation that uses hashing. Solana defaults to using SHA-256. This is what the default Anchor hashv function does. I thought that this was required, and made a branch of the arm-risc0 repo with a feature flag swapping one hash function for the other. This was actually a quite trivial change, but it might have been unnecessary. Apparently, there is a syscall in solana, sol_keccak256, and it, allegedly, has the same cost as sol_sha256, but different performance characteristics. Not entirely sure what to make of that.

Delta Verification: Balance Conservation

The delta proofs are enforced cryptographically via ECDSA signature verification. It

  1. Collects tags (nullifiers and commitments) from all compliance units in order
  2. Computes the verifying key as a hash of the concatenated tags
  3. Accumulates delta points from each compliance instance via EC point addition
  4. Verifies that the ECDSA signature recovers to a public key matching the accumulated delta

The implementation uses Solana’s secp256k1_recover syscall for signature recovery. Solana has native support for secp256k1 signature recovery, but it does not have a syscall for arbitrary EC point addition, as far as I can tell. So while step 4 uses the cheap native syscall, step 3 has to use libsecp256k1 in software:

I originally tried using libsecp256k1 for everything, and it compiles fine, but the library recovery function blew through the CU budget for a single transaction immediately, and then things were fine when I swapped out recover.

pi_a Negation

Groth16 proofs consist of three group elements: pi_a, pi_b, and pi_c. During verification, one of these needs to be negated.

The risc0-ethereum verifier negates pi_a internally. The caller passes the proof as-is, and the Solidity contract handles the negation in checkPairing by computing (q - y) mod q for the y-coordinate.

The risc0-solana verifier does not negate internally. The caller must negate pi_a before passing the proof, following the pattern used by groth16-solana where Groth16 verifiers expect the a component to be negated. All this means is our implementation needs its own negate_pi_a function. This isn’t complicated, but is a potential gotcha if you aren’t paying attention.

External Calls

The EVM Interface

The EVM Protocol Adapter defines forwarders through a minimal IForwarder interface:

function _executeForwarderCall(bytes32 carrierLogicRef, bytes calldata callBlob) internal {
    (address untrustedForwarder, bytes memory input, bytes memory expectedOutput) =
        abi.decode(callBlob, (address, bytes, bytes));

    // slither-disable-next-line calls-loop
    bytes memory actualOutput =
        IForwarder(untrustedForwarder).forwardCall({logicRef: carrierLogicRef, input: input});

    if (keccak256(actualOutput) != keccak256(expectedOutput)) {
        revert ForwarderCallOutputMismatch({expected: expectedOutput, actual: actualOutput});
    }

    // solhint-disable-next-line max-line-length
    emit ForwarderCallExecuted({untrustedForwarder: untrustedForwarder, input: input, output: actualOutput});
}

Two parameters in, one return value out. The logicRef is the resource’s logic verifying key - it tells the forwarder which resource type is making the call, allowing forwarder-side access control. The input is arbitrary bytes, interpreted however the forwarder sees fit. The output is verified against the expected output declared in the transaction.

The PA decodes each external call blob as (address forwarder, bytes input, bytes expectedOutput), calls forwarder.forwardCall(logicRef, input), and verifies that keccak256(actualOutput) == keccak256(expectedOutput). If they don’t match, the entire transaction reverts.

The Solana Equivalent

The Solana version preserves this interface while adapting to the account model:

struct SolanaExternalCall {
    program_id: [u8; 32],          // Which forwarder program
    instruction_data: Vec<u8>,     // The "input" bytes
    expected_output: Vec<u8>,      // Must match forwarder's return
    output_mode: OutputMode,       // How to read the result
}

...

fn execute_forwarder_call<'info>(
    logic_ref: &crate::types::Digest,
    call: &SolanaExternalCall,
    forwarder_program_info: &anchor_lang::prelude::AccountInfo<'info>,
    cpi_accounts: &[anchor_lang::prelude::AccountInfo<'info>],
    remaining_accounts: &[anchor_lang::prelude::AccountInfo<'info>],
) -> Result<(), PAError> {
    let program_id = *forwarder_program_info.key;

    invoke_forwarder(
        program_id,
        &logic_ref.to_bytes(),
        &call.instruction_data,
        forwarder_program_info,
        cpi_accounts,
    )?;

    let actual_output = read_forwarder_output(&call.output_mode, &program_id, remaining_accounts)?;

    verify_output(&call.expected_output, &actual_output, &call.output_mode)?;

    anchor_lang::prelude::emit!(crate::ForwarderCallExecutedEvent {
        forwarder: program_id,
        input: call.instruction_data.clone(),
        output: actual_output,
    });

    Ok(())
}

with correspondences;

EVM Solana
carrierLogicRef logic_ref
callBlob (encoded) call (already decoded)
untrustedForwarder (address) forwarder_program_info + program_id
input call.instruction_data
expectedOutput call.expected_output
IForwarder(…).forwardCall(…) invoke_forwarder(…)
actualOutput (return value) read_forwarder_output(…)
keccak256(…) != keccak256(…) verify_output(…)
emit ForwarderCallExecuted emit!(ForwarderCallExecutedEvent {…})

invoke_forwarder is more complicated than a direct call to the forwardCall function, mainly because it needs to format everything for the CPI call.

The remaining differences are bureaucracy around account management and output placement. EVM functions return bytes directly, while Solana has a 1024-byte return data limit.

Account passing happens through remaining_accounts. The transaction builder knows which forwarders will be called and what accounts each needs. These get bundled into the Solana transaction’s account list. During execution, the PA finds each forwarder’s program ID in remaining_accounts, treats subsequent accounts until the next forwarder as that forwarder’s CPI accounts, and invokes.

For output, there are two modes. The primary path uses get_return_data() - the forwarder calls set_return_data(&output), and the PA reads it immediately after the CPI. There’s an important subtlety here: if the forwarder internally invokes another program, and that program sets return data, the forwarder must set its own return data last, after the CPI call returns. Solana’s return data is last-writer-wins.

The alternative for outputs exceeding 1024 bytes is OutputMode::OutputAccount where the forwarder writes to a passed writable account, and the PA reads from a specified (offset, len) range. I haven’t actually tested it; The block time forwarder is trivially small and I’m not sure how large outputs are generally expected to be.

Conclusion

It’s a starting point.

3 Likes