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:
- User pre-constructs an ARM Transaction that creates commitment
Cwith 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. - User calls
deposit(...)on the origin-chain SpokePool withrecipient = ShieldedDepositReceiver on destination chainandmessage = abi.encode(armTransaction). - Across relayer fills the deposit on the destination chain. The SpokePool transfers WETH to the Receiver, then calls
Receiver.handleV3AcrossMessage(WETH, amount, relayer, message). - Receiver decodes the ARM Transaction from
message, approves the PA to pull its new WETH balance, and callsProtocolAdapter.execute(armTransaction)inside the same transaction. - The PA verifies the RISC Zero proofs, invokes
Receiver.forwardCallvia the Transaction’sexternalPayload, the Receiver confirms the commitment matches what it just unpacked from the message and approves the token transfer, the PA appendsCto 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
-
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’sdeposit(...)passingmessage = abi.encode(armTransaction). -
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: themessagefield is arbitrarybytes, tokens and message both go torecipient, and the call tohandleV3AcrossMessageis atomic with the token transfer (no try/catch, no best-effort). -
Shielded Deposit Receiver. Implements
AcrossMessageHandler(the interface SpokePool calls into) and is therecipientof 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
AcrossMessageHandleron entry, and theIForwarderthat the PA calls into from within the nestedexecute(armTx).consumedTxHashprevents replay: the same armTx cannot be delivered twice even if somehow submitted through two different Across deposits. -
Anoma Protocol Adapter. Reused unmodified. Same verification path as the NEAR Intents design: the PA accepts
execute(Transaction)with valid RISC Zero proofs, dispatchesexternalPayloadblobs to the Receiver’sforwardCall, and appends the commitment on success.
What the Across contracts actually allow
Verified from source:
messageisbytes, arbitrary length, on bothDepositV3Params(SpokePool.sol line 113) andV3RelayData(V3SpokePoolInterface.sol line 38).- Tokens go to
recipientviasafeTransferFrom(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
handleV3AcrossMessagereverts, the entire_transferTokensToRecipientreverts, which reverts_fillRelayV3, which revertsfillRelay. The relayer loses nothing because thesafeTransferFromis also rolled back. inputTokenandoutputTokenare 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 addressrecipient= Shielded Deposit Receiver on BaseinputToken= WETH on mainnetoutputToken= WETH on Base (0x4200...0006)inputAmountandoutputAmountsized so the spread covers the relayer’s expected cost includingPA.executegasdestinationChainId= 8453fillDeadline= a reasonable future timestampmessage=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:
- Transfers
outputAmountof WETH from the relayer to the Receiver. - 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:
- Verifies the RISC Zero proofs (compliance, logic, delta).
- Loops over
externalPayloadblobs and for each callsReceiver.forwardCall(logicRef, input). - Receiver’s
forwardCallchecks the input, transfers WETH into the PA, and returns theexpectedOutputbytes. - PA compares returned bytes against the Transaction’s
expectedOutputand, on match, appendsCto 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,handleV3AcrossMessagereverts,fillRelayreverts. 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.forwardCallreverts 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:
- AnomaPay’s existing proving service. Default for mobile and web clients.
- Local proving via
anoma/arm-risc0. Sovereign option for desktop users. - Bonsai. Backup.
- 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
Cis 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
messagelength. Themessagebytes themselves are public on the origin chain inside theFundsDepositedevent. - On the destination chain: the fill transaction, the WETH transfer from the relayer to the Receiver, the nested
execute(Transaction)call, and the commitmentCas 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
-
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. -
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. -
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. -
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.
-
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.
-
Exact-amount coupling between proof and deposit. The PA’s
expectedOutputequality check ties each proof to one specificoutputAmount; 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 tomin_amountand 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.