lattice: enforce Receive referential integrity at Ledger ingestion (PHASE2-013-A)#124
Merged
Merged
Conversation
Pre-fix `Ledger::add_block` accepted any signed `Receive` block
whose Ed25519 signature and chain-continuity validated, without
ever confirming the `Receive`'s `source_hash` corresponded to a
real `Send` block in the lattice. An attacker controlling any
peer could open a chain under their own pubkey, sign a `Receive`
citing a fabricated 64-hex `source_hash`, submit it to
`Ledger::add_block`, and have their balance grow by the amount
recorded in the `Receive`. Mints from nothing.
The CRDT reconciliation layer
(`arxia-crdt::reconcile_partitions`) already enforces source-hash
existence + destination match at reconcile time (CRIT-017,
MED-016, MED-017), so the phantom would be filtered when the
attacker's chain merges with a peer holding the legitimate
`Send` index. But `Ledger::add_block` is upstream of reconcile :
a phantom can sit on the local ledger between ingestion and
reconcile, be served via gossip, and influence finality scoring
before the merge filters it out.
Fix:
1. crates/arxia-core/src/error.rs : add
`ArxiaError::UnknownSourceSend { source_hash }`. The existing
`WrongDestination` and `DuplicateReceive` variants cover the
destination-mismatch and already-consumed cases ; the new
variant covers the "no such Send anywhere" case.
2. crates/arxia-lattice/src/ledger.rs : `Ledger` gains two
private indices.
- `send_index: HashMap<String, SendInfo>` maps every accepted
`Send` block's hash to its `(destination, amount)` pair.
- `consumed_sends: HashSet<String>` is the cross-chain
counterpart to the per-chain `AccountChain.consumed_sources`,
tracking every `source_hash` already credited by an
accepted `Receive` anywhere in the lattice.
Both indices are maintained incrementally inside `add_block`
after all other checks pass, so a rejected block never
pollutes them.
3. crates/arxia-lattice/src/ledger.rs : `add_block` runs three
ordered checks on every `Receive` block before the
chain-continuity check :
- existence : `send_index.get(source_hash)` →
`UnknownSourceSend` if missing.
- destination match : `send_info.destination == block.account`
→ `WrongDestination` if not.
- not consumed : `!consumed_sends.contains(source_hash)` →
`DuplicateReceive` if it is.
Tests added (4 in `ledger::tests`) :
- `test_add_block_accepts_legitimate_send_then_receive` —
happy-path pin (alice opens, alice sends to bob, bob opens, bob
receives, all four blocks accepted in order).
- `test_add_block_rejects_phantom_receive_no_matching_send` —
bob opens, then submits a Receive with `source_hash="ff"*32` ;
ledger returns `UnknownSourceSend`, bob's chain unchanged.
- `test_add_block_rejects_receive_with_wrong_destination` —
alice sends to bob, carol forges a Receive citing alice's Send
hash ; ledger returns `WrongDestination`, carol's chain unchanged.
- `test_add_block_rejects_double_receive_across_chains` — bob
legitimately receives once, then a second Receive on the same
source_hash returns `DuplicateReceive`.
Validation (executed) :
- arxia-lattice 95 → 99 tests (+4 net).
- stable 1.95 `cargo test --workspace` exit 0.
- MSRV 1.85 `cargo test --workspace` exit 0.
- clippy --all-targets -D warnings, fmt --check, build --workspace
all green on both toolchains.
No public API change. The `Ledger::chains` field stays public for
existing consumers ; the two new indices are private (consumers
that need the data go through `add_block` rather than mutating
state directly). Cross-workspace consumers
(`arxia-gossip`, `arxia-finality`, gossip handlers, replay
tooling) inherit the new check transparently — no caller-side
migration needed.
Follow-up commits planned (separate scope, not in this commit) :
- Global `total_supply` accumulator on `Ledger`. The constants
file already has a TODO flagging the gap explicitly ; the
follow-up introduces the accumulator the TODO anticipated.
- `MAX_INITIAL_BALANCE_PER_ACCOUNT` propagation to `Receive`.
Subordinate to the global accumulator — if the global cap is
correctly enforced, per-account propagation is defense-in-depth
rather than primary protection.
For deployments already live with the pre-fix code, a backfill
sweep that re-validates every persisted block against the
post-fix `Ledger` is recommended. Tracked separately.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
lattice: enforce Receive referential integrity at Ledger ingestion
Background
Ledger::add_blockis the only ingestion path through which averified
Blockenters the lattice's persistent index. Pre-fix itran four checks : Ed25519 signature + Blake3 hash recomputation
(via
verify_block), per-account chain continuity (NonceGap,HashChainBroken), and per-account genesis structure(
InvalidGenesis). Per-account chain continuity (HIGH-003, commit031) closed the orphan-block class for blocks within a single
chain.
While reviewing the boundary between
Ledger::add_blockand theper-chain validation logic in
crates/arxia-lattice/src/chain.rs,I noticed an asymmetry on the
Receiveblock path : theper-chain
AccountChain::receiveAPI checks both the sourceSend's structural invariants (it must be aSend, itsdestination must equal the receiver's pubkey) AND a per-chain
consumed_sources: HashSet<String>to prevent the same sourcefrom minting multiple times on one chain. But
Ledger::add_blockshort-circuited those checks. It accepted any
Receivewhosesignature and chain-continuity validated, without ever
confirming the
source_hashcorresponded to a realSendblockanywhere in the lattice.
The bug
Combined with chain construction, the gap is :
signing key — no theft required).
AccountChain::openpath.Receiveblock with a fabricatedsource_hash— any 64-character hex string at all.
Receive(the signature is over the block hash,which is over the block fields including the fabricated
source_hash).Ledger::add_block.verify_blockpasses : the signature matches the attacker'sdeclared pubkey, the hash recomputes correctly.
nonceandpreviouslineup.
add_blockreturnsOk(()). The chain is updated. Theattacker's balance has grown by the
amountthey put in theReceive.Worked example : an attacker opens a chain at balance 0, then
submits a
Receivecarryingsource_hash = "ff" * 32and abalance of 1 000 000. The ledger accepts it. From the network's
perspective, the attacker now has 1 000 000 micro-ARX that
nobody sent them.
The bug class is "mint from nothing". It is independent of every
other check
verify_blockruns : the signature is valid, thehash recomputes, the chain extends correctly. The only thing that
distinguishes the forgery from a legitimate
Receiveis theabsence of a matching
Sendblock elsewhere in the lattice — andthe ledger never looked for one.
Severity rationale
CRITICAL.
Ledger::add_blockis the only authenticated ingestionpath. Every downstream consumer (gossip propagation, finality
scoring, CRDT merge, replay) trusts an
Ok(())return for statemutation. An attacker who controls any peer can mint arbitrary
balance under their own pubkey, then
Sendto a real account inthe protocol. Detection on a deployed network requires a separate
sweep that re-validates every persisted
Receiveagainst theSendindex — which the protocol must build retroactively if thefix is shipped post-mainnet.
The protocol's CRDT-reconciliation layer
(
arxia-crdt::reconcile_partitions, CRIT-017 + MED-016 + MED-017)DOES enforce the source-hash existence + destination match at
reconcile time. So a phantom-
Receivewould be filtered when theattacker's chain merges with a peer that has the legitimate
Sendindex. But the ingestion path(
Ledger::add_block) is upstream of reconciliation : a phantomReceivecan sit on the local ledger between ingestion andreconcile, be served to peers via gossip, and influence finality
scoring before reconcile rejects it. The fix closes the upstream
gap.
Fix
Three additions to
Ledger, all O(1) per block on the hot path.Change 1 — Add
ArxiaError::UnknownSourceSendcrates/arxia-core/src/error.rsgains a new variant :WrongDestination(already present) covers the destinationmismatch.
DuplicateReceive(already present) covers thealready-consumed case. The new variant covers the
"no such Send anywhere" case — the phantom-receive specifically.
Change 2 —
Ledgergains two indicessend_indexmaps every acceptedSend's hash to its(destination, amount).consumed_sendstracks everysource_hashalready credited by an acceptedReceiveanywherein the lattice — the cross-chain counterpart to the per-chain
AccountChain.consumed_sources.The indices are maintained incrementally inside
add_blockafterall other checks pass, so a rejected block never pollutes them.
Change 3 —
add_blockvalidates Receive referential integrityThree ordered checks : existence → destination → not-consumed.
The order is deliberate. Existence is the cheapest and the most
likely to reject malformed input ; destination mismatch is next ;
double-receive is last because it requires both first checks to
have already passed against an existing
Send.The check fires BEFORE the chain-continuity check, so a rejected
phantom-
Receivereturns the canonicalUnknownSourceSenddiagnostic instead of a downstream error like
NonceGap.Validation (executed)
Receive+source_hash="ff"*32)Ledger::add_blockreturnsOk(()), balance growsErr(UnknownSourceSend{source_hash}), chain unchangedOk(())Err(WrongDestination), Carol's chain unchangedOk(())(per-chain check on first chain only)Err(DuplicateReceive{source_hash}), second chain unchangedcargo test --package arxia-lattice --libcargo test --workspacecargo test --workspacecargo clippy --workspace --all-targets -- -D warningscargo fmt --all -- --checkcargo build --workspacecargo clippy --workspace --all-targets -- -D warningscargo build --workspaceTest count delta :
arxia-lattice95 → 99 (+4 net :test_add_block_accepts_legitimate_send_then_receivehappy-pathpin,
test_add_block_rejects_phantom_receive_no_matching_send,test_add_block_rejects_receive_with_wrong_destination,test_add_block_rejects_double_receive_across_chains).Why CI didn't catch it
No pre-existing test exercised the
Ledger.add_block(receive)path. Existing tests covered Open and Send through
add_block;the
Receivepath went directly throughAccountChain::receivein the test fixtures, then never round-tripped through
add_block. The integration gap was structural :each layer's tests covered its own internal invariants
(per-chain in
chain.rs, reconciliation incrdt/reconciliation.rs), but the boundary between them atadd_blockwas untested.Distinct from other commits
This commit is the first of three closing supply-conservation
gaps on the lattice ingestion path :
Receivemint vector atLedger::add_block.total_supplyaccumulatoron
Ledger(the constants file already has a TODO flaggingthis gap explicitly ; the follow-up introduces the accumulator
the TODO anticipated).
MAX_INITIAL_BALANCE_PER_ACCOUNTto the
Receivepath (subordinate to the global accumulator —if the global cap is correctly enforced, per-account
propagation is defense-in-depth rather than primary
protection).
This commit ships solo because the phantom-
Receiveis the mostacute and most independent of the three. The other two are
larger structural changes that benefit from being bundled.
Out of scope (deferred)
total_supplyaccumulator onLedger. Trackedseparately.
Receive. Tracked separately,subordinate to the global accumulator.
ships, a
tools/scan-phantom-receives.rscould iterate everypersisted block and re-validate against the post-fix
Ledger; not in this commit's scope but recommended as a follow-up
for any deployment that has been live with the pre-fix code.