RFC: On-Chain Verification of USDC Deposits

Date: 2026-07-27

Status: Draft

Scope: treasury-engine deposit pipeline — an independent on-chain verifier for USDC deposits, its persisted output, and how that output feeds the credit gate. Covers the A-3194 epic and its children A-3424 (chain_verification module), A-3431 (chain-verification subscriber), and A-3649 (per-chain finality policy). Consumed by the credit gate A-3442; does not implement it.

0. Why this exists

Today the deposit pipeline trusts exactly one oracle: Anchorage. A deposit is eligible for credit when anchorage_deposits.status = 'SUCCESS' clears the AnchorageSuccess gate (rs/treasury-engine/src/deposit_pipeline/state.rs:72). On-chain finality is currently advisory only — DepositEvent::FinalityAlert carries confirms vs threshold but "carries no authority over crediting" (rs/treasury-engine/src/deposit_pipeline/event.rs:103), and Gate has no finality variant by deliberate design (state.rs:51).

That is a single point of trust for money movement. If Anchorage marks a deposit SUCCESS in error, prematurely, or under compromise, we credit a client for funds that did not finalize on-chain. The epic reframes the requirement: the reorg/finality safety property — do not credit below finalized depth — becomes a hard input to the credit gate (A-3442 condition 2), fed by an independent verifier that reads the chain directly rather than taking Anchorage's word.

This RFC specifies that verifier. It is defense in depth: a second, disagreeing oracle whose job is to confirm that the transaction Anchorage attributed to us actually exists on-chain, moved the USDC we expect, and is buried under enough confirmations to be irreversible.

1. What "verify" means (design decision)

The naïve reading of the ticket title ("verify on Etherscan") is "count confirmations for a tx hash." That is necessary but not sufficient. A tx hash plus a confirmation count proves some transaction is deep in the chain — not that it sent our USDC to our address. The verifier must validate the transfer semantics, not just depth.

Given the (blockchain, transaction_hash, amount, destination address) that Anchorage already persists on anchorage_deposits (rs/sdk-internal/db/src/anchorage_deposits.rs), verification is a conjunction:

  1. Existence. The tx hash is present in a mined block on the named chain.
  2. Token identity. The tx emits an ERC-20 Transfer event whose emitting contract is the canonical USDC contract for that chain (not a look-alike token with the same symbol).
  3. Recipient. The Transfer to matches the single Anchorage deposit destination address bound to the account being credited.
  4. Amount. The Transfer value equals anchorage_deposits.amount_quantity scaled to USDC's 6 decimals (exact integer compare; no float).
  5. Depth. head_block − tx_block + 1 ≥ threshold(chain), where the threshold is the per-chain finality policy from A-3649. The tx block hash must also match the canonical block hash for that height on the head-serving view before confirmations count.

Only (1)–(4) holding and (5) holding yields Verified. (1)–(4) holding with (5) not yet met is Pending (keep waiting — this is the eventually-green case). A contradiction — tx absent after a grace window, receipt block no longer canonical, wrong token, wrong recipient, wrong amount, or a previously observed tx that disappears past the finality threshold — is Contradicted, a terminal red flag that must never be treated as "still pending."

This is what makes the verifier independent: it re-derives the deposit's facts from chain state and checks them against what Anchorage told us. Agreement is the common case; the entire point of the module is to be loud when they disagree.

2. Data source (design decision)

The ticket says Etherscan. Etherscan is the fastest path to a working verifier — one HTTP API, no node to run, log-topic filtering server-side — but it is a centralized third party with a bespoke per-chain API, and we would be using it to gate money. The alternative is plain JSON-RPC, the same protocol every node and managed provider (Alchemy, Infura, or self-hosted) speaks:

Option Pros Cons
A. Etherscan (and per-chain equivalents: Basescan, Arbiscan, …) Ships fast; server-side log filtering Centralized; rate limits; a bespoke API per explorer to model
B. Standard JSON-RPC (managed, self-hosted, or public keyless node) One protocol across chains and providers; no per-explorer API; swappable in one URL Client-side receipt/log parse; a provider unless self-hosted

Decision: build against standard JSON-RPC (Option B). It is one protocol for every chain and provider, needs no per-explorer modelling, and the trust dependency is swapped by changing a URL. v1 targets eth_getTransactionReceipt

trait ChainProvider: Send + Sync {
    /// Current chain head height.
    fn head(&self, chain: Chain) -> impl Future<Output = Result<u64>> + Send;
    /// Canonical block hash for a height, plus the same view's head.
    fn canonical_block(&self, chain: Chain, block: u64)
        -> impl Future<Output = Result<CanonicalBlock>> + Send;
    /// The mined tx (block + decoded ERC-20 Transfer logs), or `Ok(None)` if no
    /// receipt exists yet (pending mempool or never mined — caller distinguishes
    /// by age). Transient RPC/HTTP errors are `Err`, never `None`.
    fn mined_tx(&self, chain: Chain, tx: TxHash)
        -> impl Future<Output = Result<Option<MinedTx>>> + Send;
}

JsonRpcProvider is one impl; an Etherscan adapter could be another. It is built on alloy-rpc-client through the generic ax-blockchain provider helper: method + serializable params in, typed decoded result out. The canonicality check uses a JSON-RPC batch for eth_blockNumber + eth_getBlockByNumber, so the block hash and head are derived from one provider operation before depth is computed. We adopt alloy-primitives and alloy-sol-types for EVM bytes and ERC-20 decoding, but intentionally do not adopt alloy-provider: the fail-closed reqwest transport and receipt JSON parsing stay thin and local. No async-trait (native RPITIT). The trait draws the same line the codebase already draws elsewhere: transient errors (timeout, 429, 5xx) are Err and leave the deposit Pending; only a real on-chain contradiction is terminal. A rate-limited RPC call must never look like "deposit contradicted" — that mirrors the existing gate rule that a transient RPC failure leaves a gate pending, not failed (state.rs:87).

3. chain_verification module (A-3424)

The split keeps reusable, token-agnostic EVM plumbing (ax-blockchain) below the treasury-specific USDC deposit policy:

enum Verification {
    Verified { block: u64, block_hash: BlockHash, confirms: u64, to: Address, log_index: u64 },
    Pending  { block: Option<u64>, block_hash: Option<BlockHash>, confirms: u64, tx_present: bool },
    Contradicted(Contradiction),  // terminal
}

enum Contradiction {
    NotFoundPastGrace,   // absent for longer than the ingest→mine grace window
    NonCanonicalBlock,   // receipt block still non-canonical past the finality threshold
    WrongToken,          // Transfer contract ≠ canonical USDC for the chain
    WrongRecipient,      // `to` != expected deposit destination
    AmountMismatch { expected: u128, seen: Vec<u128> },
    Reorged { previous_block: u64 },  // previously observed tx absent past finality
}

verify() takes the expected facts (chain, the single expected to, expected amount, threshold, prior-seen block if any) and a generic P: ChainProvider, and returns a Verification. It contains zero I/O and zero clocks beyond the injected provider — same testability discipline as DepositRecord::apply, so it can be exhaustively unit-tested against a mock provider (found/not-found, wrong-token, wrong-amount, exactly-at-threshold, one-below-threshold, reorg).

Threshold policy (A-3649)

threshold(chain) is the one genuinely open question and is owned by A-3649 (now In Review). This RFC only constrains its shape: a static per-chain map, config-overridable, defaulting to a conservatively-finalized depth per chain (e.g. Ethereum: ~2 epochs / 64 blocks to reach the finalized checkpoint rather than a probabilistic N-confirmations count). The module reads the resolved threshold; it does not decide it. Until A-3649 lands, the verifier can run in observe-only mode (§6) with a placeholder threshold — but per A-3442 we must not hardcode SUCCESS-only crediting and call it done.

4. chain-verification subscriber (A-3431)

The module is inert logic; the subscriber drives it. It follows the existing poller pattern (deposit_poller.rs, attribution_poller.rs): a cursor-driven async task owned by treasury-engine, plus a bus subscription for liveness.

Trigger. Confirmations are eventually-green, so — exactly like the credit gate (A-3442) — verification is not one-shot. The subscriber re-evaluates on two signals:

  1. Event-driven: subscribe to DepositBus (bus.rs); on Ingested for a deposit that has an on-chain transaction_hash, run a first verification.
  2. Periodic sweep: on an interval, re-verify every deposit that is Pending (found on-chain, semantics OK, depth still below threshold) until it reaches Verified or Contradicted. This is what carries a deposit from "1 confirm" to "finalized" without needing a new bus event per block.

The sweep is cursor-bounded like the Anchorage pollers (advance only on successful persist; bounded startup lookback) so a restart resumes cleanly and a long outage doesn't stampede Etherscan.

Persistence. A new Postgres table chain_verifications in the treasury_engine schema, keyed by deposit_transaction_id (FK to anchorage_deposits):

column meaning
deposit_transaction_id (pk) Anchorage tx id
chain resolved Chain
tx_hash on-chain hash verified
observed_block last block the tx mined in (updated when a matching tx re-mines)
observed_block_hash receipt block hash used for canonicality/reorg checks
confirms last observed depth
tx_present whether the current check saw the receipt (false means prior-block depth only)
threshold threshold in force at evaluation
status PENDING | VERIFIED | CONTRADICTED
contradiction nullable enum detail when CONTRADICTED
first_seen_at, last_checked_at immutable / monotone, per existing upsert idiom

Upsert follows the anchorage_deposits discipline: IS DISTINCT FROM guards to avoid WAL churn on re-observed rows; first_seen_at is preserved, while observed_block and observed_block_hash are updated when the same matching tx re-mines so the next check uses the latest prior block identity.

Output — two channels, matching the code's existing split:

  1. Advisory, today's contract: emit DepositEvent::FinalityAlert { confirms, threshold } on the bus (event.rs:105). This preserves the current monitoring surface and stays idempotent (Apply::NoOp on re-delivery). No change to the bus wire format.
  2. Authoritative, new: the chain_verifications row is the input the credit gate reads (§5). The bus stays advisory; the table is the durable fact. This is a deliberate choice — the gate must re-query durable state on every evaluation, not depend on having heard an ephemeral broadcast (broadcast drops for lagged/absent subscribers by design, bus.rs).

5. How the credit gate consumes this (A-3442, not built here)

A-3442's condition 2 is "chain confirmations ≥ per-chain finality threshold." Concretely that becomes a JOIN in the gate's evaluation query: chain_verifications.status = 'VERIFIED' for the deposit. The mapping to the gate's re-evaluation semantics:

Reconciling the advisory-vs-gate tension. The current code comments assert finality is advisory and Anchorage SUCCESS is the finality anchor (state.rs:52, mod.rs). This RFC does not delete that machinery — FinalityAlert stays. It adds an independent authoritative signal alongside it. The Gate enum in state.rs is intentionally left at three variants; finality enters through the credit gate's condition set (A-3442), not as a fourth deposit_pipeline::Gate. That keeps the elegant three-gate join (CreditGate.tla, DepositStateMachine.tla) intact while satisfying the epic's reframing. When A-3442 lands, the stale "SUCCESS is the finality anchor" comments in state.rs/mod.rs must be updated in the same PR to avoid asserting an invariant the code no longer holds.

6. Rollout — observe before enforce

Same philosophy as continuous-recon-checks.md: never swap a money tripwire in one step. Three phases:

  1. Shadow. Subscriber runs, populates chain_verifications, emits FinalityAlert. Credit gate does not read it. We accumulate weeks of (Anchorage SUCCESS) vs (our VERIFIED) agreement data. Any CONTRADICTED in this window is a pure alert and a bug hunt — either a real problem or a verifier defect — and must be understood before phase 2.
  2. Soft-enforce. Credit gate reads chain_verifications but only while auto_credit_enabled is OFF (A-3442's default): a disagreement blocks nothing automatically (nothing is auto-credited yet) but downgrades the deposit to manual review with the contradiction surfaced to finance.
  3. Enforce. With auto_credit_enabled ON (requires the finance sign-off A-3442 already gates on), VERIFIED is a hard precondition for auto-credit.

Config lives with the other treasury pollers (rs/treasury-engine/src/config.rs, AnchoragePollConfig neighbor): sweep interval, startup lookback, per-chain RPC URLs, per-chain threshold overrides, and an observe_only/enforce flag driving the phases above. Env-var override pattern identical to ANCHORAGE_POLL_*.

7. Failure modes & safety

8. Testing

9. Out of scope

10. Open questions

  1. Explorer vs RPC for v1. Resolved: standard JSON-RPC (Option B), §2. One protocol across chains, no per-explorer API, swap by URL. An Etherscan adapter stays a later drop-in behind the same trait.
  2. Grace window for "not found." How long after Anchorage Ingested before an absent tx flips from Pending to NotFoundPastGrace? Must exceed realistic mempool-to-mine + explorer-index lag per chain. Proposed: per-chain config, generous default.
  3. Amount exactness. Do we require exact value equality, or tolerate a dust delta? Recommend exact — USDC transfers are integer base units; any delta is a real discrepancy worth a human look.
  4. Multi-transfer txs. If a single tx contains several Transfer logs (batched deposit), match on the (to, value) pair rather than assuming one log. Confirm Anchorage never aggregates multiple client deposits into one attributed tx before relying on a single-match assumption.