Skip to content

fix(security): canonical UTXO recipients, rollback mirror cleanup, serialized hw binding - #8465

Merged
Scottcjn merged 3 commits into
mainfrom
fix/ondrej-utxo-canonical-addr-hwbind-toctou
Sep 18, 2026
Merged

Scottcjn merged 3 commits into
mainfrom
fix/ondrej-utxo-canonical-addr-hwbind-toctou

Conversation

@Scottcjn

Copy link
Copy Markdown
Owner

Fixes three findings from Ondrej Nad (#2819 / #71). Each was checked against origin/main before any change was made.

# Bug Fix
1 /utxo/transfer accepted mixed-case to_address. Derived addresses are lower-case and owner matching is case-sensitive, so the resulting box could never be spent. The regex is now RTC[0-9a-f]{40} and non-canonical addresses get a 400. Rejecting rather than lower-casing avoids altering what the sender signed.
2 rollback_genesis() left orphan account_mirror_boxes rows behind. Inside the same BEGIN IMMEDIATE, and only when a genesis existed, delete every mirror row that no live box backs. Dual-write rows are never touched when there is no genesis.
3 bind_hardware_v2() ran its collision check and insert without a shared transaction, so two concurrent registrations of the same machine under different serials could both bind. BEGIN IMMEDIATE is taken before the lookup, and the collision scan reuses the same connection.

Verification

  • Each new regression test fails on main and passes here. The concurrency test reproduces the double-bind on the old code and passed 5 out of 5 runs on the new code.
  • The utxo, genesis, rollback, dual-write, hw-binding and attest suites were run file by file, before and after: 0 regressions. The 18 failures that remain fail identically on main.
  • Lock cost: about 23 ms per bind at 10× current production rows. The production DB runs in WAL mode.
  • ruff: unchanged.

Review

This went through a tri-brain review, but a degraded one: Codex was unavailable (usage limit), so only the Grok regression lens ran. Its findings were applied: narrowed mirror cleanup, test isolation, and no duplicate test runs. Its "canonicalize instead of reject" and lock-duration concerns were declined for the reasons above. Please get a second pair of eyes before merging (wallet / anti-spoof code).

Related follow-up (not in this PR)

The account-model ledger has the same case problem. balances holds 236.26 RTC and 3 RTC under two mixed-case spellings of RTC5800896ed658aa511d029361b6d7388ddb29248b, from 17 founder_community payouts in May–June. Reconciling those balances needs a maintainer decision.

🤖 Generated with Claude Code

…rialized hw binding

Three verified findings reported by Ondrej Nad (#2819 / #71):

1. /utxo/transfer accepted upper/mixed-case hex in to_address. Address
   derivation yields lower-case hex and ownership matching is case-sensitive,
   so such a transfer created a box no key could ever spend (permanent fund
   lock). The canonical regex is now RTC[0-9a-f]{40}; non-canonical case is
   rejected (400) before signature checks or state mutation. Rejecting rather
   than lower-casing avoids changing what the sender signed.

2. rollback_genesis() left account_mirror_boxes provenance rows behind (no
   FK/cascade), and re-migration over changed balances added new rows beside
   them. Inside the existing BEGIN IMMEDIATE, and only when a genesis
   existed (the guard then proves all UTXO state was genesis-only), delete
   every mirror row not backed by a live box. Live dual-write rows are left
   untouched when no genesis exists.

3. bind_hardware_v2() ran the cross-serial entropy-collision scan on a
   separate connection outside any transaction, so two concurrent first-time
   registrations of the same physical machine under different serials could
   both see "no collision" and both bind. Now BEGIN IMMEDIATE is taken before
   the existing-serial lookup and the scan reuses the same connection
   (check_entropy_collision gains an optional conn=). Busy timeout 30s;
   measured lock hold ~23 ms at 10x current prod rows (DB is WAL).

Tests: each new regression test fails on origin/main and passes here
(concurrency test reproduces the double-bind on old code; 5/5 stable).
Per-file comparison of utxo/genesis/rollback/dual-write/hw-binding/attest
suites: 0 regressions; the 18 remaining failures fail identically on main.

Reported-by: Ondrej Nad (hsmmkdnw84-creator)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Scott <scottbphone12@gmail.com>
@github-actions github-actions Bot added BCOS-L1 Beacon Certified Open Source tier BCOS-L1 (required for non-doc PRs) BCOS-L2 Beacon Certified Open Source tier BCOS-L2 (required for non-doc PRs) node Node server related tests Test suite changes size/L PR: 201-500 lines labels Sep 18, 2026
@github-actions

github-actions Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

⚠️ BCOS v2 Scan Results

Metric Value
Trust Score 36/100
Certificate ID BCOS-96c00f3a
Tier L2 (not met)

BCOS Badge

What does this mean?

The BCOS (Beacon Certified Open Source) engine scans for:

  • SPDX license header compliance
  • Known CVE vulnerabilities (OSV database)
  • Static analysis findings (Semgrep)
  • SBOM completeness
  • Dependency freshness
  • Test infrastructure evidence
  • Review attestation tier

Full report | What is BCOS?


BCOS v2 Engine - Free & Open Source (MIT) - Elyan Labs

@Scottcjn
Scottcjn merged commit 70454c0 into main Sep 18, 2026
12 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

RTC Reward

This merged PR earned 5 RTC — sent to RTC5800896ed658aa511d029361b6d7388ddb29248b.

RustChain Bounty Program

Scottcjn pushed a commit that referenced this pull request Sep 19, 2026
…; annotate bounded fetch

- The tests used 'bob' as the recipient. /utxo/transfer format-checks
  to_address since #8396/#8465 (lower-case RTC + 40 hex), so the transfer
  returned 400 before reaching the code under test. Use a canonical address.
- test_dual_write_exact_balance_goes_to_zero seeded 100 RTC of UTXO against
  a 10 RTC account. With receiver/change outputs registered as account
  mirrors, the 90 RTC change exceeds the (now 0) account balance and the
  MIRROR_EXCEEDS_BALANCE guard correctly fails closed. Seed the consistent
  dual-write state (UTXO == account) the invariant assumes.
- Annotate the per-transfer output fetch for the fetchall guard (bounded by
  one transfer's outputs).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Scott <scottbphone12@gmail.com>
Scottcjn added a commit that referenced this pull request Sep 19, 2026
…venance (#8388)

* fix(utxo): dual-write reward boxes exactly mirror the account credit

Two ways finalize_epoch's UTXO_DUAL_WRITE mint diverged from the account
model it is supposed to mirror (#2819, favoritegrandson-tech):

1. Precision split: the account credit truncated the share to 6 decimals
   (amount_i64) while the mint truncated the same Decimal to 8 decimals,
   so each reward box was up to 99 nRTC larger than the credit and
   /utxo/integrity reported the models disagreeing after every epoch with
   fractional shares. The mint is now derived from the truncated credit:
   amount_nrtc = amount_i64 * (UTXO_UNIT // ACCOUNT_UNIT). A module-level
   assert pins that ratio as an exact integer.
2. Ghost mint: a miner enrolled without a balances row gets no account
   credit (no-phantom invariant) but was still minted a UTXO box. The
   mint is now gated on the account row actually being credited.

This is a prerequisite for #8394 (registering reward boxes as mirrors
must not create mirrors larger than the balance they mirror).

Tests (end-to-end finalize_epoch, dual-write on): the reward box equals
the credit x100 for co-prime weights, a ghost gets nothing, and the
credited miner still mirrors exactly while a ghost dilutes the weight.
Both fail on main (45 nRTC drift; 0.75 RTC ghost mint) and pass here.
tests/ + epoch/UTXO/dual-write suites: 0 regressions (4192 -> 4194).
UTXO_DUAL_WRITE is off and prod has 0 UTXO rows, so there is no history
to reconcile.

Reported-by: favoritegrandson-tech
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Scott <scottbphone12@gmail.com>

* fix(utxo): register epoch mining-reward boxes as account-mirror provenance (danaher #2819, reward path)

Same cross-model double-spend class as the /utxo/transfer receiver residual,
in the epoch settlement path. Under UTXO_DUAL_WRITE=1, finalize_epoch credits
each miner's ACCOUNT balance (primary ledger) AND mints a UTXO reward box for
them — but never registered those boxes in account_mirror_boxes. So each reward
was spendable via BOTH models (UTXO box + account balance) = double spend; a
total-only integrity check stays models_agree=True. Latent today (prod runs
UTXO_DUAL_WRITE=0).

Fix:
- account_mirror_boxes is now part of the canonical UTXO SCHEMA_SQL, so
  init_tables() creates it for every dual-write writer. This avoids a CREATE
  TABLE inside finalize_epoch's open settlement transaction, which Python's
  sqlite3 would implicit-commit (splitting the atomic epoch settle). (Comment in
  SCHEMA_SQL is kept free of the ';' character — _execute_schema splits on it.)
- finalize_epoch registers every reward box of each dual-write batch into
  account_mirror_boxes (located by the batch's creation_height; apply_transaction
  enforces one mining_reward per height, so all boxes at that height are exactly
  the batch's outputs). Pure INSERTs, same connection/transaction as the settle.

The mirror-input exclusion then blocks re-spending these boxes via the UTXO
path, forcing the reward to move through the account path (single spend).

Tests (node/test_utxo_dualwrite_reward_provenance.py): init_tables creates the
table; a registered reward box is excluded from UTXO spendable candidates
(double-spend closed) while an unregistered one is not (control); finalize_epoch
wires the registration inside the dual-write block (source/AST, matching
test_epoch_utxo_dual_write_guard's convention). All fail on origin/main, pass
here. UTXO + settlement suites: 165 passed, no new failures (2 pre-existing
genesis-migration failures are identical on origin/main).

Companion to PR #8388 (transfer receiver residual). Keep UTXO_DUAL_WRITE=0 on
prod until both merge. danaher-j #2819 bounty remains held pending destination.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbyXP4eiiRYEa8GsQtQPhR

* fix(utxo): register only this epoch's mint outputs as reward mirrors

The reward-mirror registration selected every box at batch_height. The
one-mining_reward-per-height guard only limits MINT transactions: an
ordinary /utxo/transfer box can share that height (both use slot numbers),
and would be tagged as an account mirror, locking the user's own funds
(every spend -> 409 ACCOUNT_MIRROR_BOX_NOT_SPENDABLE). Join on the
mining_reward tx so only this batch's mint outputs are registered.
Materialize the rows (bounded by UTXO_MAX_OUTPUTS) because the INSERTs
reuse the same cursor.

End-to-end test (real finalize_epoch, dual-write on, a user transfer box
at the batch height): the user box is not tagged, each reward box is
registered once, and each mirror equals its account credit exactly
(needs the precision fix underneath). Fails on the previous height-only
query ('user-box' tagged) and on main (nothing registered).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Scott <scottbphone12@gmail.com>

* fix(utxo): register dual-write transfer outputs as account-mirror provenance (danaher #2819 receiver residual)

Under UTXO_DUAL_WRITE=1, `balances` (account) is the primary ledger and UTXO
boxes are its shadow; `account_mirror_boxes` records which boxes back account
value, with the consensus invariant `mirror <= balance`. The mirror-input
exclusion already blocks spending a mirror box via the UTXO path (input side,
fixed earlier). The RESIDUAL: a /utxo/transfer credits the receiver's account
balance AND creates a spendable UTXO output box for the receiver, but never
registered that output as mirror provenance — so the same value was spendable
via BOTH models (UTXO output box + account credit) = double spend. A total-only
integrity check stays models_agree=True throughout (danaher-j private report,
#2819 residual; latent today: UTXO_DUAL_WRITE is off on prod).

Fix:
- utxo_db.apply_transaction now exposes the authoritative tx_id on the caller's
  tx dict (tx['tx_id']), so the endpoint can locate the boxes it created.
- The dual-write branch of /utxo/transfer registers every output box of the
  transfer (receiver at index 0, change at index 1) into account_mirror_boxes,
  so the unconditional mirror-input exclusion blocks re-spending them via UTXO;
  the value must move via the account path (which consumes the mirror on settle).
- Added a fail-closed per-wallet `mirror <= balance` assertion (compared in nRTC:
  mirror value_nrtc vs balance amount_i64 * (UNIT//ACCOUNT_UNIT)); a violation
  rolls the transfer back rather than committing money that exists twice.

Tests: node/test_utxo_dualwrite_receiver_provenance.py (3 tests) — receiver
output is mirror-registered, receiver box is excluded from UTXO spendable
candidates (double-spend closed), invariant holds. All 3 fail on origin/main and
pass with this change. Existing UTXO suites: 154 passed, no new failures (17
pre-existing POC/isolation failures are identical on origin/main).

RELATED (not fixed here, flagged for follow-up): the epoch mining-reward
dual-write path (rustchain_v2_integrated...:~5698) likewise creates UTXO reward
boxes without account_mirror_boxes provenance — same class, same latency behind
UTXO_DUAL_WRITE=0. Keep UTXO_DUAL_WRITE=0 on prod until both are resolved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbyXP4eiiRYEa8GsQtQPhR

* test(utxo): update receiver-provenance tests for canonical recipients; annotate bounded fetch

- The tests used 'bob' as the recipient. /utxo/transfer format-checks
  to_address since #8396/#8465 (lower-case RTC + 40 hex), so the transfer
  returned 400 before reaching the code under test. Use a canonical address.
- test_dual_write_exact_balance_goes_to_zero seeded 100 RTC of UTXO against
  a 10 RTC account. With receiver/change outputs registered as account
  mirrors, the 90 RTC change exceeds the (now 0) account balance and the
  MIRROR_EXCEEDS_BALANCE guard correctly fails closed. Seed the consistent
  dual-write state (UTXO == account) the invariant assumes.
- Annotate the per-transfer output fetch for the fetchall guard (bounded by
  one transfer's outputs).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Scott <scottbphone12@gmail.com>

---------

Signed-off-by: Scott <scottbphone12@gmail.com>
Co-authored-by: Scott <scottbphone12@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

BCOS-L1 Beacon Certified Open Source tier BCOS-L1 (required for non-doc PRs) BCOS-L2 Beacon Certified Open Source tier BCOS-L2 (required for non-doc PRs) node Node server related size/L PR: 201-500 lines tests Test suite changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants