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.
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.
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:
Transfer
event
whose
emitting contract
is
the
canonical
USDC
contract
for
that
chain
(not
a
look-alike token
with
the
same
symbol).
Transfer
to
matches
the
single
Anchorage
deposit destination
address
bound
to
the
account
being
credited.
Transfer
value
equals
anchorage_deposits.amount_quantity scaled
to
USDC's
6
decimals
(exact
integer
compare;
no
float).
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.
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
eth_blockNumber
only;
a
public
keyless
node
is
acceptable
for
reads
(the receipt
is
self-describing
and
the
verifier
re-checks
every
field).
The
verifier depends
on
a
trait,
not
on
any
one
provider:
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).
chain_verification
module
(A-3424)The
split
keeps
reusable,
token-agnostic
EVM
plumbing
(ax-blockchain)
below
the treasury-specific
USDC
deposit
policy:
rs/sdk-internal/blockchain/src/chain.rs
—
the
TxHash/BlockHash
byte-normalized identifiers
and
the
Chain
enum
(typed,
closed
set:
Ethereum;
growing
it
is
a deliberate
code
+
config
change,
never
a
raw
String
passthrough).
No
USDC
or deposit
semantics
live
here.
rs/sdk-internal/blockchain/src/erc20.rs
—
Alloy-backed
ERC-20
Transfer
decoder (Erc20Transfer
+
decode_transfer),
carrying
log_index.
rs/treasury-engine/src/chain_verification/usdc.rs
—
the
deposit-specific
policy layered
on
the
generic
Chain:
the
canonical
USDC
contract
address
per
chain
and chain_from_anchorage
mapping
anchorage_deposits.blockchain.
An
unknown blockchain
string
fails
closed
(verification
cannot
proceed
→
the
deposit
stays Pending
and
pages,
never
auto-credits).
The
decimal→base-unit
conversion
of
an expected
amount
(A-3431)
will
add
a
usdc_decimals
source
of
truth
here
when
it lands.
rs/treasury-engine/src/chain_verification/provider.rs
—
the
ChainProvider trait,
treasury
JsonRpcProvider
(one
retrying,
timeout-bounded
RPC
client
per chain
over
eth_getTransactionReceipt
and
a
canonical-block
batch),
the CanonicalBlock
view,
and
Alloy
rpc-types-eth
receipt/log
decoding
into USDC-oriented
MinedTx
transfers.
rs/treasury-engine/src/chain_verification/verify.rs
—
the
pure
function:
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(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.
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:
DepositBus
(bus.rs);
on
Ingested
for
a deposit
that
has
an
on-chain
transaction_hash,
run
a
first
verification.
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:
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.
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).
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:
VERIFIED
→
condition
2
satisfied.
PENDING
→
keep
waiting;
do
not
emit
Failed.
Consistent
with
A-3442's rule
that
pending
confirmations
mean
wait,
not
fail.
CONTRADICTED
→
terminal
Failed(credit_gate, chain_contradiction),
and page.
This
is
the
case
Anchorage
said
SUCCESS
but
the
chain
disagrees
—
the exact
scenario
this
epic
exists
to
catch.
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.
Same
philosophy
as
continuous-recon-checks.md:
never
swap
a
money
tripwire
in one
step.
Three
phases:
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.
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.
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_*.
Err
→
deposit
stays
PENDING, never
CONTRADICTED.
A
verifier
outage
delays
credit;
it
never
fabricates
a contradiction
and
never
fast-tracks
one.
Staleness
of
last_checked_at
past a
threshold
is
itself
a
recon
signal
(mirror
anchorage-deposits-up-to-date, STALENESS_THRESHOLD_SECS).
PENDING
until
the previously
observed
block
crosses
the
finality
threshold,
then
becomes Reorged.
This
is
precisely
the
CreditGate_Reorg
hazard
A-3442
cites; catching
it
below
finalized
depth
is
the
whole
point
of
the
depth
check.
blockHash to
the
canonical
block
hash
for
that
height,
but
a
mismatch
is
only
terminal (NonCanonicalBlock)
once
that
block
is
buried
past
the
finality
threshold; below
it
the
deposit
stays
PENDING
and
re-checks,
so
a
transient
split between
load-balanced
backends
never
rejects
a
good
deposit.
state.rs:9).
The
threshold
(A-3649)
must
be
deep enough
that
post-finalization
reversal
is
not
a
realistic
event;
that
is
why the
depth
check
gates
before
credit,
not
after.
Chain::try_from
fails
closed
→
PENDING
+ page,
never
auto-credit.
New
chains
are
an
explicit
code+config
change.
verify.rs
unit
tests
against
a
mock
ChainProvider:
found-and-deep, found-shallow
(Pending),
not-found-within-grace
(Pending)
vs not-found-past-grace
(Contradicted),
wrong-token,
wrong-recipient, amount-off-by-one-unit,
non-canonical
receipt
block,
exactly-at-threshold, one-below,
moved-block
re-track,
below-threshold
disappearance,
and
final
reorg-out.
Pure
function
→
exhaustive
and
fast, same
as
state.rs's
transition
grid.
DepositBus
harness
pattern (tests/deposit_pipeline.rs):
drive
Ingested,
assert
row
transitions PENDING → VERIFIED
across
sweep
ticks
with
a
scripted
provider,
and
assert
a contradiction
lands
CONTRADICTED
and
emits
the
alert.
PENDING
(never
flips
to
CONTRADICTED).
Per
repo
policy
on disconnect/restart/recovery
paths
for
anything
touching
risk/money
state.
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.
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.
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.