[Idea] Anoma Protocol Adapter × Across Protocol

Anoma Protocol Adapter × Across Protocol

tl;dr

  • Product: deposit transparent tokens on any EVM chain, receive a shielded note in AnomaPay on the destination chain, in one atomic destination-chain transaction. No pre-register step, no asynchronous settle, no NEAR leg.
  • Note: privacy follows the same at-rest-only principle as the NEAR Intents design. The Across relay leg is public. What this integration does is preserve the shielded status of the destination-chain balance inside AnomaPay’s pool; the origin-to-destination transfer is visible.

Note: this document makes no claims about product market fit, user stories, or consumer/business demand. this document was produced in conjunction with opus 4.7 and flash 3.1.

The problem

The NEAR Intents integration (companion doc) solves transparent ZEC to shielded ETH. For users who already hold transparent tokens on EVM chains (USDC on mainnet, WETH on Arbitrum, ETH on Optimism), that design is overkill: routing via NEAR is an unnecessary hop, and NEAR Intents cannot pass destination calldata which forced a two-phase pre-register plus settle model.

A user holding transparent WETH on mainnet who wants a shielded WETH note inside AnomaPay’s pool on Base should not need to touch NEAR. The source and destination are both EVM. A cross-chain bridge that understands arbitrary destination calldata is the right primitive.

[ ] Reader beware, privacy at rest only, not in flight. The same Zooko Wilcox-O’Hearn caveat from the NEAR Intents doc applies: “You can’t get privacy from value in flight, you can only get privacy from value at rest.” Across’s relay layer is transparent. Amounts, origin chain, destination chain, destination contract, and timing are all public. This integration preserves the shielded status of the destination-side balance inside AnomaPay’s pool. The transfer itself is not private.

The solution

Across v3’s SpokePool accepts a bytes message field on every deposit. When a relayer fills on the destination chain, the SpokePool transfers the output tokens to recipient and then calls recipient.handleV3AcrossMessage(outputToken, amount, relayer, message) atomically. If the call reverts, the entire fill reverts. Verified by reading contracts/spoke-pools/SpokePool.sol lines 1643 to 1677.

That single property collapses the architecture we used for NEAR Intents. There is no pre-register, no two-phase binding, no separate settle transaction. The flow is:

  1. User pre-constructs an ARM Transaction that creates commitment C with a RISC Zero proof. Proving is delegated to AnomaPay’s existing service or generated locally; bring-your-own-proof is still a first-class property.
  2. User calls deposit(...) on the origin-chain SpokePool with recipient = ShieldedDepositReceiver on destination chain and message = abi.encode(armTransaction).
  3. Across relayer fills the deposit on the destination chain. The SpokePool transfers WETH to the Receiver, then calls Receiver.handleV3AcrossMessage(WETH, amount, relayer, message).
  4. Receiver decodes the ARM Transaction from message, approves the PA to pull its new WETH balance, and calls ProtocolAdapter.execute(armTransaction) inside the same transaction.
  5. The PA verifies the RISC Zero proofs, invokes Receiver.forwardCall via the Transaction’s externalPayload, the Receiver confirms the commitment matches what it just unpacked from the message and approves the token transfer, the PA appends C to the commitment tree.

Everything settles in one destination-chain transaction: Across fill, token transfer, ARM Transaction execution, commitment insertion. The relayer pays the gas and is repaid through the Across spread model. No Anoma-side relayer or tip mechanism is needed because Across already operates the relayer network.

Why this is better than the NEAR Intents design for EVM flows

Property NEAR Intents version Across version
Atomicity Three-phase (pre-register, bridge, settle) over several minutes One transaction on destination chain, single fill
Pre-registration Required on-chain before intent submission Not required
Custom relayer We run one for meta-tx plus settle Across’s existing relayer network fills the deposit
Tip accounting Custom, stored in Pending struct Built into Across’s input-to-output spread
Destination calldata Not supported; forced the pre-register workaround First-class via message field
Source chains Anything PoA-bridged (including ZEC, LTC, BTC, XRP, Tron, Sui) EVM-only (mainnet, Arbitrum, Optimism, Base, Polygon, zkSync, Linea, Scroll, Blast, more)
Native ZEC Yes, via PoA bridge No, not an EVM chain
Destination-chain gas before first deposit Problem, needed EIP-712 meta-tx Not a problem; relayer pays destination gas
Audit surface Receiver plus DepositProxy plus factory, ~300 lines Receiver only, ~100 lines

The two designs are complementary, not competing. NEAR Intents handles non-EVM sources (most importantly ZEC). Across handles EVM sources. Both target the same destination: an AnomaPay shielded pool on an EVM chain.

Architecture

┌─────────────────┐          ┌────────────────────────┐
│  USER CLIENT    │───(1)───▶│ SpokePool              │
│ (off-chain)     │          │ (origin chain)         │
│  prove, build   │  deposit │ e.g. mainnet           │
│  ARM Transaction│  message │ 0xFBc81a...5AE80         │
└─────────────────┘  =       └───────────┬────────────┘
armTx               │
(2) emits FundsDeposited
│
┌───────────────────┴──────────────┐
│   ACROSS RELAYER NETWORK         │
│   fills fastest profitable       │
└───────────────────┬──────────────┘
│ (3) fillRelay
▼
┌──────────────────────────────────────────────────────┐
│ DESTINATION SPOKEPOOL (e.g. Base 0x6C99...5E399)       │
│   transfers outputToken to recipient                 │
│   calls recipient.handleV3AcrossMessage(...)         │
└──────────────────────────────┬───────────────────────┘
│ (4) atomic
▼
┌──────────────────────────────────────────────────────┐
│ SHIELDED DEPOSIT RECEIVER (our contract, Base)       │
│   decodes armTx from message                         │
│   approves PA, calls execute(armTx)                  │
└──────────────────────────────┬───────────────────────┘
│ (5) execute(Transaction)
▼
┌──────────────────────────────────────────────────────┐
│ ANOMA PROTOCOL ADAPTER (Base 0x094FCC...dd84F8)        │
│   verifies RISC Zero proofs                          │
│   calls Receiver.forwardCall for the externalPayload │
└──────────────────────────────┬───────────────────────┘
│ (6) forwardCall
▼
┌───────────────────┐
│ Receiver verifies │
│ commitment, sends │
│ WETH to PA.       │
│ PA appends C.     │
└───────────────────┘

The four components

  1. User client. Generates the nullifier key, nonce, randomness, commitment, and the encrypted note ciphertext. Pre-builds the ARM Transaction via bring-your-own-proof: either delegates witness to AnomaPay’s existing proving service (default for mobile and web) or proves locally with anoma/arm-risc0. Calls the origin-chain SpokePool’s deposit(...) passing message = abi.encode(armTransaction).

  2. Across Protocol. Used unmodified. SpokePools on both chains, HubPool on mainnet, UMA Optimistic Oracle as the root-bundle validator, established relayer network competing for fills. Our design depends on three facts we verified by reading contracts/spoke-pools/SpokePool.sol: the message field is arbitrary bytes, tokens and message both go to recipient, and the call to handleV3AcrossMessage is atomic with the token transfer (no try/catch, no best-effort).

  3. Shielded Deposit Receiver. Implements AcrossMessageHandler (the interface SpokePool calls into) and is the recipient of every cross-chain deposit routed through this design. On the destination chain only. Responsibilities:

    contract ShieldedDepositReceiver is AcrossMessageHandler {
    address public immutable pa;
    address public immutable spokePool;
    mapping(bytes32 => bool) public consumedTxHash;
    
    

    function handleV3AcrossMessage(
    address token,
    uint256 amount,
    address /* relayer */,
    bytes calldata message
    ) external {
    require(msg.sender == spokePool, “not-spokepool”);
    bytes32 txHash = keccak256(message);
    require(!consumedTxHash[txHash], “replay”);
    consumedTxHash[txHash] = true;

    Transaction memory armTx = abi.decode(message, (Transaction));
    IERC20(token).approve(pa, amount);
    IProtocolAdapter(pa).execute(armTx);
    

    }

    function forwardCall(bytes32 logicRef, bytes calldata input)
    external returns (bytes memory)
    {
    require(msg.sender == pa && logicRef == LOGIC_REF, “unauthorized”);
    (address asset, uint256 amount, bytes32 commitment) =
    abi.decode(input, (address, uint256, bytes32));
    require(IERC20(asset).balanceOf(address(this)) >= amount, “underfunded”);
    IERC20(asset).transfer(pa, amount);
    return abi.encode(asset, amount, commitment);
    }

    
    }
    

    The Receiver plays two roles in one contract: it is the AcrossMessageHandler on entry, and the IForwarder that the PA calls into from within the nested execute(armTx). consumedTxHash prevents replay: the same armTx cannot be delivered twice even if somehow submitted through two different Across deposits.

  4. Anoma Protocol Adapter. Reused unmodified. Same verification path as the NEAR Intents design: the PA accepts execute(Transaction) with valid RISC Zero proofs, dispatches externalPayload blobs to the Receiver’s forwardCall, and appends the commitment on success.

What the Across contracts actually allow

Verified from source:

  • message is bytes, arbitrary length, on both DepositV3Params (SpokePool.sol line 113) and V3RelayData (V3SpokePoolInterface.sol line 38).
  • Tokens go to recipient via safeTransferFrom(msg.sender, recipientToSend, amountToSend) at SpokePool.sol line 1664.
  • Message is invoked at SpokePool.sol lines 1668 to 1676 as AcrossMessageHandler(recipientToSend).handleV3AcrossMessage(outputToken, amountToSend, msg.sender, updatedMessage).
  • No try/catch wraps the call. If handleV3AcrossMessage reverts, the entire _transferTokensToRecipient reverts, which reverts _fillRelayV3, which reverts fillRelay. The relayer loses nothing because the safeTransferFrom is also rolled back.
  • inputToken and outputToken are independent. A user can deposit USDC on mainnet and specify WETH on Base as the output, with the relayer absorbing the swap as part of their margin.

MulticallHandler at contracts/handlers/MulticallHandler.sol is a production utility that decodes a list of (target, callData, value) calls from message and executes them. Deployed at 0x0F7Ae28dE1C8532170AD4ee566B5801485c13a0E on both mainnet and Base. We do not use it because our Receiver is its own handler, but its existence confirms the pattern works in practice and is widely integrated.

Flow 1: transparent WETH on mainnet to shielded WETH on Base

Client-side setup

User generates nk, nonce, randomness, target amount. Client computes the PositionNote commitment per the Poseidon schema in the NEAR Intents doc. Client builds the ARM Transaction: an ephemeral-input compliance unit consumed, a PositionNote with commitment C created, logic proofs, a delta proof, and an externalPayload blob targeting the destination-chain Receiver’s forwardCall with input abi.encode(WETH_base, amount, C) and expectedOutput = abi.encode(WETH_base, amount, C).

Proving is delegated to AnomaPay’s existing service by default for mobile and web clients. Desktop users can prove locally with anoma/arm-risc0. Either way, the user ends up with a fully signed, RISC Zero-proven Transaction struct ready to ABI-encode.

Step 1: deposit on mainnet

Client calls SpokePool.deposit(...) on Ethereum mainnet at 0xFBc81a18EcDa8E6A91275cFDF5FC6d91A7C5AE80 with:

  • depositor = user’s mainnet address
  • recipient = Shielded Deposit Receiver on Base
  • inputToken = WETH on mainnet
  • outputToken = WETH on Base (0x4200...0006)
  • inputAmount and outputAmount sized so the spread covers the relayer’s expected cost including PA.execute gas
  • destinationChainId = 8453
  • fillDeadline = a reasonable future timestamp
  • message = abi.encode(armTransaction)

User approves WETH to the SpokePool before the call, or sends native ETH as msg.value if depositing ETH.

Step 2: Across fills on Base

Across relayer picks up the deposit from the FundsDeposited event, evaluates profitability including the destination-chain gas for our nested execute(Transaction), and calls fillRelay on the Base SpokePool at 0x6C99671B249af73B2847D92123d823Cb3875E399. The SpokePool:

  1. Transfers outputAmount of WETH from the relayer to the Receiver.
  2. Calls Receiver.handleV3AcrossMessage(WETH_base, outputAmount, relayerAddress, armTxBytes).

Step 3: Receiver executes the ARM Transaction

Receiver decodes the armTx from message, confirms it has not seen this exact payload before, approves the PA to spend its new WETH balance, and calls `ProtocolAdapter.execute(armTx)`. The PA:

  1. Verifies the RISC Zero proofs (compliance, logic, delta).
  2. Loops over externalPayload blobs and for each calls Receiver.forwardCall(logicRef, input).
  3. Receiver’s forwardCall checks the input, transfers WETH into the PA, and returns the expectedOutput bytes.
  4. PA compares returned bytes against the Transaction’s expectedOutput and, on match, appends C to the commitment tree.

All of steps 2 and 3 live inside the relayer’s single fillRelay call on Base. If anything reverts, the whole chain reverts, and the relayer never gets a refundable fill.

Failure modes

  • Proof invalid: execute(armTx) reverts, handleV3AcrossMessage reverts, fillRelay reverts. The relayer takes no loss. The deposit on mainnet remains unfilled until another relayer picks it up or the fill deadline passes, at which point the depositor can reclaim their input tokens via Across’s standard slow-fill or timeout mechanism.
  • Relayer does not fill: deposit expires and the user reclaims. Identical to any Across deposit.
  • PA paused or bug discovered mid-flow: same as proof-invalid. Relayer loses nothing, user reclaims on mainnet.
  • Token amount mismatch (e.g. PoA-style fee deducted): should not happen on Across because relayers transfer the full outputAmount; if a relayer somehow transfers less, Receiver.forwardCall reverts at the balance check.

Flow 2: shielded WETH (AnomaPay pool on mainnet) to shielded WETH (AnomaPay pool on Base)

The cross-pool shielded-to-shielded case. Conceptually similar to the NEAR version but requires one new contract on the source chain.

A new source-chain contract: AcrossDepositForwarder

The PA’s _executeForwarderCalls loops over externalPayload[] and dispatches each blob via IForwarder(untrustedForwarder).forwardCall(logicRef, input). It cannot directly call SpokePool.deposit because SpokePool does not implement IForwarder, and the deployed ERC20Forwarder’s Unwrap produces a plain ERC-20 transfer which SpokePool does not process as a deposit. Flow 2 therefore requires a new AcrossDepositForwarder on the source chain: a contract implementing IForwarder, registered with its own LOGIC_REF, that holds a WETH balance (funded via the Wrap side of the flow or via Permit2) and on forwardCall decodes the deposit params from input and calls SpokePool.deposit(...) directly. Roughly 80 lines of Solidity, one new LOGIC_REF, its own audit.

Step 1: build both sides

Client builds two ARM Transactions. tx1 consumes the user’s shielded note on the mainnet pool (RISC Zero compliance proof with Merkle inclusion against the mainnet PA’s tree, logic proof, delta proof). tx2 creates commitment C_B on the Base pool, structurally identical to Flow 1. tx1’s externalPayload targets the new AcrossDepositForwarder with input encoding the deposit params, including recipient = Base Receiver and message = abi.encode(tx2).

Step 2: submit on mainnet

User submits execute(tx1) on mainnet. The PA verifies proofs, calls AcrossDepositForwarder.forwardCall(...), and that forwarder calls SpokePool.deposit(...) with message = abi.encode(tx2). The mainnet-side note is consumed (nullifier published), the deposit enters Across’s mempool.

Step 3: Across fills on Base

Identical to Flow 1 Step 2 and 3. Relayer fills on Base; Receiver decodes tx2 from message, executes it via PA.execute(tx2), and C_B lands in the Base pool’s commitment tree.

Asynchrony budget

Tx1 is one mainnet tx plus an Anoma proof. Tx2 runs inside the relayer’s fill on Base. Across fill times on mainnet-to-Base are reported in low-single-digit minutes; not independently measured. No other async phases.

Proving

Same story as the NEAR Intents doc.

Bring-your-own-proof

The PA verifies any valid RISC Zero proof regardless of source. Prover options in order of expected MVP use:

  1. AnomaPay’s existing proving service. Default for mobile and web clients.
  2. Local proving via anoma/arm-risc0. Sovereign option for desktop users.
  3. Bonsai. Backup.
  4. Any other RISC Zero prover. Interchangeable outputs.

The client SDK is prover-agnostic. Witness handling is the only trust question; whoever receives nk can leak it or front-run spends. Users pick per their threat model.

What needs proving

Every deposit requires an ARM Transaction with RISC Zero proofs. Flow 1 uses a minimal compliance unit (ephemeral input consumed, PositionNote created). Flow 2 Step 1 is heavier because it consumes a real shielded note with Merkle inclusion against the source pool. Flow 2 Step 3 on the destination side is the same as Flow 1.

Privacy

Hidden

  • The identity of the ultimate owner of the new shielded note inside the destination AnomaPay pool. Commitment C is an opaque hash; only the user knows the preimage.
  • For Flow 2, the link between the consumed source-pool note and the created destination-pool note. Nullifier publication on the source and commitment creation on the destination are in separate transactions with no on-chain pointer between them.

Visible

  • At the Across layer: the input token, the input amount, the output token, the output amount, the destination chain, the destination address (the Receiver), and the message length. The message bytes themselves are public on the origin chain inside the FundsDeposited event.
  • On the destination chain: the fill transaction, the WETH transfer from the relayer to the Receiver, the nested execute(Transaction) call, and the commitment C as it lands in the tree.

The nuance

The message field is public. That means the ARM Transaction (including the commitment, the compliance and logic proofs, and the delta proof) is publicly inspectable on the origin chain. The proofs do not leak the preimage of nk, nonce, or rcm, but an observer can see exactly which commitment is being created, for what amount, and at what time. This is the same information Flow 1 publishes anyway on the destination chain. Privacy is at rest only.

Client mitigations

Commitment diversification (split across multiple deposits, accept multiple fees); timing jitter on deposit submission; RPC hygiene for portfolio queries (private-transport or split-RPC).

Opt-in compliance via viewing keys

Same as the NEAR Intents doc. User holds their viewing key and can voluntarily export a signed history disclosure; the protocol never holds disclosure-capable keys.

Open problems and honest limits

  1. Relayer economics. Our fills are more expensive than a plain Across fill because the relayer’s gas includes PA.execute (proof verification, tree insertion, forwardCall). Relayers price this into the spread; users pay a small premium. Quantifiable via benchmarking post-deploy.

  2. ARM Transaction size. The full Transaction with proofs is non-trivial calldata, embedded inside Across message. Origin-chain calldata gas and cross-chain relay data both scale with it.

  3. Relayer sophistication. Whether the current relayer fleet simulates a deposit that triggers a nested execute(Transaction) with RISC Zero verification is unverified against relayer code.

  4. Origin-chain proof visibility. The ARM Transaction lives on the origin chain in the deposit event. Not a cryptographic regression; the proof is already public on-chain. Worth documenting, not engineering around.

  5. Across optimistic window. Relayers receive their refund only after the HubPool bundle passes the 2-hour UMA liveness window undisputed. Does not affect users (they have their note at fill time); relayers hold capital for the window and price it into the spread.

  6. Exact-amount coupling between proof and deposit. The PA’s expectedOutput equality check ties each proof to one specific outputAmount; Across fills exactly that amount (SpokePool.sol line 1664, no tolerance). If the quote goes stale before a fill, the user re-quotes and re-proves. AnomaPay’s proving-service latency gates that friction; seconds-scale proving keeps the loop tolerable. v2 fix: a minimum-amount proof where the circuit binds to min_amount and any delta between actual and minimum is routed to the relayer as a bonus tip. Keeps user identity out of the delta path and turns slippage tolerance into a relayer incentive. Not MVP scope.

1 Like