Skip to content

feat(payment): pay N merkle trees in one tx (payForMerkleTrees) - #15

Open
Nic-dorman wants to merge 1 commit into
mainfrom
feat/pay-for-merkle-trees
Open

feat(payment): pay N merkle trees in one tx (payForMerkleTrees)#15
Nic-dorman wants to merge 1 commit into
mainfrom
feat/pay-for-merkle-trees

Conversation

@Nic-dorman

@Nic-dorman Nic-dorman commented Aug 17, 2026

Copy link
Copy Markdown
Member

Linear issue

V2-989 — https://linear.app/withautonomi/issue/V2-989

Risk tier

  • T0 — docs / tooling / CI / pure UX-output. Repo CI only.
  • T1 — client-only, no network-facing behavior change. CI + prod compat smoke.
  • T2 — node/client logic with behavioral surface, no protocol/format/economics change. Dev testnet + ADR.
  • T3 — protocol / storage format / payments / routing. T2 evidence + adversarial testing.

Compatibility

  • Wire: none (nodes are payment-tx-agnostic; they verify via the getCompletedMerklePayment state getter — zero node changes)
  • Storage: none (contract storage layout untouched for existing mappings; the new entry point writes the same completedMerklePayments records)
  • API: additive — new contract entry point payForMerkleTrees (legacy payForMerkleTree byte-for-byte untouched), new evmlib functions (Wallet::pay_for_merkle_trees, external_signer::pay_for_merkle_trees_calldata, handler batched methods) and constants (MAX_TREES_PER_PAYMENT, MERKLE_TREES_PER_PAYMENT). One behavioral fix: estimation-time reverts now surface as typed TransactionReverted with revert data (previously stringified TransactionFailedToSend retried 3×).

Semver impact

  • breaking
  • feature
  • fix

Test evidence

  • forge test: 15/15 — new adversarial suite test/PaymentVaultV2.t.sol: event ordering + attribution (log order == input order), revert atomicity (wrong pool count mid-batch, depth-too-large, depth-0 panic, allowance short by one — no partial state survives), duplicate winner within a batch + replay across entry points (PaymentAlreadyExists), seed decorrelation of identical trees via the tree index, empty/oversized-batch bounds, 16-tree max accepted, legacy entry-point regression, setBatchLimit onlyOwner.
  • cargo test --release --features external-signer: 34/34 — incl. new Anvil integration tests: batched round trip via handler and Wallet (3 trees, per-tree winner/amount/storage verified), duplicate rejection surfacing typed PaymentAlreadyExists through the gas-estimation revert path, external-signer raw-calldata e2e (approve → submit calldata → exactly one stored payment per tree).
  • cargo clippy --all-targets --all-features clean, cargo fmt --check clean.
  • Deliberately deferred to V2-991 (per plan): real-wallet Arbitrum Sepolia WalletConnect test with a ~70–85 KiB batched payload before freezing the client cap at 4.

New dependency

  • forge-std v1.16.2 (git submodule + foundry.lock, test-only — standard Foundry test harness; the repo previously had no Solidity test infra). No Rust dependency changes.

ADR

ADR-0011 (Proposed), filed in ant-node via WithAutonomi/ant-node#213 — permanent home: docs/adr/ADR-0011-batched-merkle-tree-payments.md (branch link; resolves on main once #213 merges). Original draft (numbered ADR-0010, pre-renumber) also posted as a comment below.

Mitigation / rollback

Nothing deploys from this PR; the new entry point reaches production only via V2-992 (fresh vault deployment + evmlib address bump, node-first rollout). Rollback = revert the PR / don't deploy. payForMerkleTree is unchanged, so released clients (ant-ui 0.9.7, mobile 0.0.7) are unaffected on both old and new vaults.

🤖 Generated with Claude Code

…010)

Adds the batched entry point so external-signer uploads need one wallet
confirmation per <=4 trees instead of one per tree (V2-989):

- contract: payForMerkleTrees(MerkleTreePayment[]), all-or-nothing,
  per-tree MerklePaymentMade events in input order, MAX_TREES_PER_PAYMENT=16
  constant getter as the client feature probe; winner-pool seed gains the
  tree index; repo contract aligned with the deployed production source
  (Ownable + setBatchLimit, solc 0.8.33)
- rust: Wallet::pay_for_merkle_trees, external_signer::
  pay_for_merkle_trees_calldata, handler decodes ALL MerklePaymentMade
  events of a tx in log order; MERKLE_TREES_PER_PAYMENT=4 calldata cap
  exported for consumers
- retry: estimation-time reverts keep their revert data so contract errors
  (e.g. PaymentAlreadyExists) decode to typed errors instead of being
  retried and stringified
- tests: Foundry adversarial suite (15) + Anvil integration tests for the
  batched path incl. external-signer raw-calldata e2e

Linear: V2-989

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Nic-dorman

Copy link
Copy Markdown
Member Author

ADR-0010 draft (for filing in ant-node docs/adr/ADR-0010-batched-merkle-tree-payments.md; local copy at ~/adr-0010-batched-merkle-tree-payments.md):


ADR-0010: Batched Merkle Tree Payments (payForMerkleTrees)

  • Status: Proposed
  • Date: 2026-08-17
  • Decision owners: Nic Dorman
  • Reviewers:
  • Supersedes: none
  • Superseded by: none
  • Related: ADR-0008 (storage economics and payment protocol), Linear V2-949 (design), V2-989 (contract + evmlib), V2-990 (ant-core), V2-991 (ant-ui), V2-992 (deployment), WithAutonomi/evmlib PR (payForMerkleTrees)

Context

External-signer uploads (ant-ui via WalletConnect/MetaMask, mobile SDKs) pay
per merkle tree: each ≤256-chunk (~1 GiB) tree is one payForMerkleTree
transaction, so a large upload costs one allowance approval plus N wallet
confirmations
. Field experience with ant-ui 0.9.7 confirmed the N-approval
flow is painful (7 GB ≈ 7 confirmations).

Two facts shape the solution space:

  • Nodes are payment-tx-agnostic. ant-node verifies merkle payments via the
    getCompletedMerklePayment(winnerPoolHash) state getter plus cryptographic
    checks against the pool; it never inspects the paying transaction, its
    calldata, or its events. All node caches are pool-hash/XorName-keyed.
    Batching N payments into one transaction therefore requires zero node
    changes
    .
  • The deployed vaults cannot be upgraded in place. Both production vaults
    (Arbitrum One, Arbitrum Sepolia) are plain contracts — no proxy slots, no
    DELEGATECALL (verified on-chain 2026-08-17). A new entry point ships via
    redeployment and an evmlib address bump, with a node-first rollout
    (tracked in V2-992).

Decision Drivers

  • One wallet confirmation per ~4 GiB instead of one per ~1 GiB slice.
  • Released clients (ant-ui 0.9.7, mobile SDK 0.0.7) must keep working
    unchanged against both old and new vaults.
  • Arbitrum sequencer TxMaxDataSize (95,000 bytes) caps calldata per
    transaction; a depth-8 tree is ≈17 KiB of ABI-encoded calldata.
  • Replay protection (completedMerklePayments[winnerPoolHash]) and the
    economic formula (median16(prices) × 2^depth, per-node transfers) must be
    preserved exactly.

Considered Options

  1. Contract-level batch entry point payForMerkleTrees(trees[]) — one
    transaction, atomic, works with every wallet.
  2. EIP-5792 wallet_sendCalls — batches at the wallet layer; availability
    and atomicity vary per wallet, no contract guarantee; unusable as the
    primary mechanism (remains a future additive lever).
  3. Multicall-style aggregator contract — breaks the msg.sender-seeded
    winner selection and the allowance model (the aggregator would become the
    payer).
  4. Status quo with UX polish — keeps N confirmations; rejected by field
    feedback.

Decision

We will add an additive entry point to PaymentVaultV2 (option 1) and
redeploy:

struct MerkleTreePayment {
    uint8 depth;
    uint64 merklePaymentTimestamp;
    PoolCommitment[] poolCommitments;
}

function payForMerkleTrees(MerkleTreePayment[] calldata trees)
    external returns (bytes32[] memory winnerPoolHashes, uint256 totalAmount);
  • All-or-nothing: trees are processed in input order; any failing tree
    (depth, pool count, duplicate winner pool, transfer failure) reverts the
    whole transaction.
  • Events unchanged: one MerklePaymentMade per tree, emitted in input
    order; clients attribute event → tree by log order. winnerPoolHashes is
    returned aligned to input order.
  • On-chain bound: MAX_TREES_PER_PAYMENT = 16; the public constant getter
    doubles as the client feature probe against old deployments (revert/absent →
    legacy per-tree path).
  • Seed fix: the winner-pool seed gains the tree index —
    keccak(prevrandao, block.timestamp, msg.sender, ts, treeIndex) — so trees
    paid in one transaction select winners independently. The legacy entry point
    passes index 0. Selection is computed and stored in the same transaction, so
    the formula change has no cross-transaction compatibility impact.
  • Economics unchanged: per-node safeTransferFrom is retained; a batch
    costs exactly the sum of the per-tree costs, so summed-allowance semantics
    carry over.
  • Client-side cap: evmlib exports MERKLE_TREES_PER_PAYMENT = 4 as the
    recommended trees-per-transaction cap (≈4 GiB per confirmation) derived from
    the calldata sizing above; consumers chunk by this constant instead of
    hardcoding it. The on-chain 16 leaves headroom.
  • Back-compat: payForMerkleTree is kept forever. The repo contract is
    also aligned with the deployed production source (Ownable +
    setBatchLimit), which the previous repo copy lacked.

evmlib grows the matching surface: Wallet::pay_for_merkle_trees,
external_signer::pay_for_merkle_trees_calldata (one calldata blob + approve
info), and a handler that decodes all MerklePaymentMade events of the
transaction in log order (the previous implementation decoded only the first).
Deterministic reverts surfaced at gas-estimation time now retain their revert
data, so contract errors like PaymentAlreadyExists reach callers as typed
errors instead of opaque strings after pointless retries.

Consequences

Positive

  • One confirmation per ≤4 trees: a 7 GB upload drops from 7 confirmations
    to 2.
  • Zero node changes; released clients unaffected; old vault keeps serving
    during the transition.
  • Same-transaction winner-selection correlation is fixed by the seed change.
  • Typed contract errors from estimation-time reverts improve every payment
    path (no more 3×-retry of deterministic reverts).

Negative / Trade-offs

  • Requires redeployment and a node-first rollout (fresh vault state; nodes
    verify against the vault address in their own config) — V2-992.
  • All-or-nothing means one already-paid tree poisons its batch; clients must
    submit only unpaid trees (upload re-preparation already skips stored
    chunks, and PaymentAlreadyExists is now reliably surfaced).
  • Uploads beyond the calldata cap still need multiple confirmations (7 GB =
    2); further levers (packed calldata, deeper trees, EIP-5792) are separate
    decisions.

Neutral / Operational

  • The seed formula changes for both entry points at the redeploy boundary;
    winner selection remains on-chain-only, so nothing off-chain recomputes it.
  • New-vault ownership (EOA vs Safe) and deploy tooling are decided in V2-992.

Validation

  • Foundry suite (evmlib test/PaymentVaultV2.t.sol, 15 tests): event
    ordering + attribution, revert atomicity (wrong pool count mid-batch,
    depth-too-large, depth-0 panic, allowance short by one), duplicate winner
    within a batch and replay across entry points, seed decorrelation of
    identical trees, empty/oversized batch bounds, max-batch acceptance, legacy
    entry-point regression, setBatchLimit onlyOwner.
  • Anvil integration (Rust): batched round trip via handler and via
    Wallet, duplicate rejection surfacing typed PaymentAlreadyExists,
    external-signer raw-calldata end-to-end (approve → submit calldata → verify
    one stored payment per tree).
  • Before freezing the client cap at 4: real-wallet Arbitrum Sepolia test
    pushing a ~70–85 KiB batched payload through the WalletConnect relay
    (V2-991), plus a LAN devnet rerun of the full upload path (V2-990/991).
  • Review trigger: any change to tree depth economics (depth > 8) or to
    Arbitrum's TxMaxDataSize revisits MERKLE_TREES_PER_PAYMENT.

Notes for AI-assisted work

AI tools may help draft this ADR, but must not mark it Accepted without
human review
. Accepted ADRs are immutable: create a new superseding ADR
rather than editing an Accepted ADR.

@Nic-dorman
Nic-dorman marked this pull request as ready for review August 21, 2026 17:30
Nic-dorman pushed a commit to WithAutonomi/ant-node that referenced this pull request Aug 21, 2026
…ees)

Filed from the draft posted on WithAutonomi/evmlib#15 (V2-989, parent
V2-949). Status: Proposed. Numbered 0011: 0010 was taken on main by the
beta upgrade channel ADR after this draft was numbered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant