Skip to content

lattice: enforce Receive referential integrity at Ledger ingestion (PHASE2-013-A)#124

Merged
ArxiaLayer1 merged 1 commit into
mainfrom
fix/PHASE2-013-A-receive-referential-integrity
May 10, 2026
Merged

lattice: enforce Receive referential integrity at Ledger ingestion (PHASE2-013-A)#124
ArxiaLayer1 merged 1 commit into
mainfrom
fix/PHASE2-013-A-receive-referential-integrity

Conversation

@ArxiaLayer1
Copy link
Copy Markdown
Owner

lattice: enforce Receive referential integrity at Ledger ingestion

Background

Ledger::add_block is the only ingestion path through which a
verified Block enters the lattice's persistent index. Pre-fix it
ran 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, commit
031) closed the orphan-block class for blocks within a single
chain.

While reviewing the boundary between Ledger::add_block and the
per-chain validation logic in crates/arxia-lattice/src/chain.rs,
I noticed an asymmetry on the Receive block path : the
per-chain AccountChain::receive API checks both the source
Send's structural invariants (it must be a Send, its
destination must equal the receiver's pubkey) AND a per-chain
consumed_sources: HashSet<String> to prevent the same source
from minting multiple times on one chain. But Ledger::add_block
short-circuited those checks. It accepted any Receive whose
signature and chain-continuity validated, without ever
confirming the source_hash corresponded to a real Send block
anywhere in the lattice
.

The bug

Combined with chain construction, the gap is :

  • An attacker controls a chain (their own pubkey, their own
    signing key — no theft required).
  • They open the chain via the standard AccountChain::open path.
  • They construct a Receive block with a fabricated source_hash
    — any 64-character hex string at all.
  • They sign the Receive (the signature is over the block hash,
    which is over the block fields including the fabricated
    source_hash).
  • They submit the block to Ledger::add_block.
  • verify_block passes : the signature matches the attacker's
    declared pubkey, the hash recomputes correctly.
  • The chain-continuity check passes : nonce and previous line
    up.
  • add_block returns Ok(()). The chain is updated. The
    attacker's balance has grown by the amount they put in the
    Receive.

Worked example : an attacker opens a chain at balance 0, then
submits a Receive carrying source_hash = "ff" * 32 and a
balance 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_block runs : the signature is valid, the
hash recomputes, the chain extends correctly. The only thing that
distinguishes the forgery from a legitimate Receive is the
absence of a matching Send block elsewhere in the lattice — and
the ledger never looked for one.

Severity rationale

CRITICAL. Ledger::add_block is the only authenticated ingestion
path. Every downstream consumer (gossip propagation, finality
scoring, CRDT merge, replay) trusts an Ok(()) return for state
mutation. An attacker who controls any peer can mint arbitrary
balance under their own pubkey, then Send to a real account in
the protocol. Detection on a deployed network requires a separate
sweep that re-validates every persisted Receive against the
Send index — which the protocol must build retroactively if the
fix 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-Receive would be filtered when the
attacker's chain merges with a peer that has the legitimate
Send index. But the ingestion path
(Ledger::add_block) is upstream of reconciliation : a phantom
Receive can sit on the local ledger between ingestion and
reconcile, 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::UnknownSourceSend

crates/arxia-core/src/error.rs gains a new variant :

#[error("receive references unknown source send: {source_hash}")]
UnknownSourceSend {
    source_hash: String,
},

WrongDestination (already present) covers the destination
mismatch. DuplicateReceive (already present) covers the
already-consumed case. The new variant covers the
"no such Send anywhere" case — the phantom-receive specifically.

Change 2 — Ledger gains two indices

pub struct Ledger {
    pub chains: HashMap<String, Vec<Block>>,
    send_index: HashMap<String, SendInfo>,        // NEW
    consumed_sends: HashSet<String>,              // NEW
}

struct SendInfo {
    destination: String,
    amount: u64,                                  // reserved for B
}

send_index maps every accepted Send's hash to its
(destination, amount). consumed_sends tracks every
source_hash already credited by an accepted Receive anywhere
in the lattice — the cross-chain counterpart to the per-chain
AccountChain.consumed_sources.

The indices are maintained incrementally inside add_block after
all other checks pass, so a rejected block never pollutes them.

Change 3 — add_block validates Receive referential integrity

if let BlockType::Receive { source_hash } = &block.block_type {
    let send_info = self.send_index.get(source_hash)
        .ok_or_else(|| ArxiaError::UnknownSourceSend {
            source_hash: source_hash.clone(),
        })?;
    if send_info.destination != block.account {
        return Err(ArxiaError::WrongDestination);
    }
    if self.consumed_sends.contains(source_hash) {
        return Err(ArxiaError::DuplicateReceive {
            source_hash: source_hash.clone(),
        });
    }
}

Three 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-Receive returns the canonical UnknownSourceSend
diagnostic instead of a downstream error like NonceGap.

Validation (executed)

Configuration Pre-fix Post-fix
Phantom-receive probe (signed Receive + source_hash="ff"*32) Ledger::add_block returns Ok(()), balance grows returns Err(UnknownSourceSend{source_hash}), chain unchanged
Wrong-destination probe (Carol forges Receive citing Alice's Send-to-Bob hash) Ok(()) Err(WrongDestination), Carol's chain unchanged
Duplicate-receive probe (second Receive with same source_hash) Ok(()) (per-chain check on first chain only) Err(DuplicateReceive{source_hash}), second chain unchanged
Happy path (alice opens, alice sends to bob, bob opens, bob receives) green green
stable 1.95 cargo test --package arxia-lattice --lib 95 pass 99 pass (+4 regression guards)
stable 1.95 cargo test --workspace green green
MSRV 1.85 cargo test --workspace green green
stable 1.95 cargo clippy --workspace --all-targets -- -D warnings green green
stable 1.95 cargo fmt --all -- --check green green
stable 1.95 cargo build --workspace green green
MSRV 1.85 cargo clippy --workspace --all-targets -- -D warnings green green
MSRV 1.85 cargo build --workspace green green

Test count delta : arxia-lattice 95 → 99 (+4 net :
test_add_block_accepts_legitimate_send_then_receive happy-path
pin, 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 Receive path went directly through
AccountChain::receive in 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 in
crdt/reconciliation.rs), but the boundary between them at
add_block was untested.

Distinct from other commits

This commit is the first of three closing supply-conservation
gaps on the lattice ingestion path :

  • This commit closes the phantom-Receive mint vector at
    Ledger::add_block.
  • A follow-up commit will add a global total_supply accumulator
    on Ledger (the constants file already has a TODO flagging
    this gap explicitly ; the follow-up introduces the accumulator
    the TODO anticipated).
  • A second follow-up will propagate MAX_INITIAL_BALANCE_PER_ACCOUNT
    to the Receive path (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-Receive is the most
acute and most independent of the three. The other two are
larger structural changes that benefit from being bundled.

Out of scope (deferred)

  • Global total_supply accumulator on Ledger. Tracked
    separately.
  • Per-account cap propagation to Receive. Tracked separately,
    subordinate to the global accumulator.
  • Backfill scan tooling for a deployed network. Once this fix
    ships, a tools/scan-phantom-receives.rs could iterate every
    persisted 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.

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.
@ArxiaLayer1 ArxiaLayer1 merged commit f64bbe5 into main May 10, 2026
7 checks passed
@ArxiaLayer1 ArxiaLayer1 deleted the fix/PHASE2-013-A-receive-referential-integrity branch May 10, 2026 20:29
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