Skip to content

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

Merged
Scottcjn merged 3 commits into
mainfrom
fix/utxo-dualwrite-reward-provenance
Sep 19, 2026
Merged

Scottcjn merged 3 commits into
mainfrom
fix/utxo-dualwrite-reward-provenance

Conversation

@Scottcjn

Copy link
Copy Markdown
Owner

Companion to #8388. Closes the same cross-model double-spend class in the epoch settlement path (found while fixing the /utxo/transfer receiver residual).

The bug

Under UTXO_DUAL_WRITE=1, finalize_epoch credits each miner's account balance (the primary ledger) and mints a UTXO reward box for them — but never registered those boxes in account_mirror_boxes. So each mining 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 canonical UTXO schema (SCHEMA_SQL), so init_tables() creates it for every dual-write writer. This deliberately avoids a CREATE TABLE inside finalize_epoch's open settlement transaction — Python's sqlite3 issues an implicit COMMIT before DDL, which would split the atomic epoch settle. (The schema comment is kept free of the ; character, since _execute_schema splits SCHEMA_SQL on it — a real gotcha caught in testing.)
  • 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 on the same connection/transaction.

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

Tests (node/test_utxo_dualwrite_reward_provenance.py)

  • init_tables creates account_mirror_boxes;
  • 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).

Notes

🤖 Generated with Claude Code

https://claude.ai/code/session_01KbyXP4eiiRYEa8GsQtQPhR

@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 size/M PR: 51-200 lines labels Sep 11, 2026
@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

⚠️ BCOS v2 Scan Results

Metric Value
Trust Score 36/100
Certificate ID BCOS-8022541e
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

@favoritegrandson-tech

Copy link
Copy Markdown

Heads-up before merging: the reward boxes this registration picks up don't equal the balance credits they shadow. The mint truncates the same amount_decimal twice — 8 dp for the box value, 6 dp for the balances credit — so the box exceeds the credit by up to 99 nRTC per miner per epoch, and mirror ≤ balance fails at creation: integrity_check() returns mirror_exceeds_account, _settle_account_transfer_in_utxo() raises mirror_exceeds_balance/pending/confirm can never settle transfers touching those wallets, and the now-mirror-tagged box is UTXO-blocked as well (reward frozen in both models).

Repro against main (real finalize_epoch → your registration semantics → the real reconcile fn) plus a one-line fix that makes box value exactly 100 × credit:

Scottcjn/rustchain-bounties#2819 (comment)

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>
Crypteaucajun and others added 2 commits September 19, 2026 13:41
…nance (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
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>
@Scottcjn
Scottcjn force-pushed the fix/utxo-dualwrite-reward-provenance branch from 124ff24 to 3f73034 Compare September 19, 2026 18:48
@github-actions github-actions Bot added the tests Test suite changes label Sep 19, 2026
@Scottcjn

Copy link
Copy Markdown
Owner Author

Updated (rebased + tightened).

  1. Stacked on fix(utxo): dual-write reward boxes exactly mirror the account credit #8481 (the precision fix + credit gate). Its commit shows here until fix(utxo): dual-write reward boxes exactly mirror the account credit #8481 merges, so merge fix(utxo): dual-write reward boxes exactly mirror the account credit #8481 first. Without it, the reward mirrors registered here would be up to 99 nRTC larger than the credits they mirror, and a ghost miner's reward would become a mirror with no balance behind it.
  2. Registration tightened. The original query tagged every box at batch_height. The one-mint-per-height guard only limits mining_reward transactions, and ordinary /utxo/transfer boxes use slot heights in the same number space. A user's transfer at the first slot of the settled epoch would have been tagged as a mirror and locked (409). It now joins on the mining_reward tx.
  3. New end-to-end test (node/tests/test_finalize_epoch_reward_mirror_registration.py): real finalize_epoch, dual-write on, and a user transfer box placed at the batch height.
    • original query: ❌ 'user-box' tagged as a mirror
    • main: ❌ reward box not registered
    • this version: ✅ the user box is untouched, each reward box is registered once, and each mirror equals its account credit exactly.

Regression check against the #8481 base (CI tests/ plus the epoch, UTXO and dual-write suites): 0 regressions, 4195 → 4199.

@github-actions github-actions Bot added size/L PR: 201-500 lines and removed size/M PR: 51-200 lines labels Sep 19, 2026
Scottcjn added a commit that referenced this pull request Sep 19, 2026
…8481)

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

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>
@Scottcjn
Scottcjn merged commit 0ab9ddc into main Sep 19, 2026
14 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

RTC Reward

This merged PR earned 5 RTC — sent to Scottcjn.

RustChain Bounty Program

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>
Scottcjn added a commit that referenced this pull request Sep 19, 2026
…ses (#8485)

finalize_epoch() mints UTXO reward boxes under UTXO_DUAL_WRITE, but
production does not settle through it: cron -> POST /rewards/settle ->
settle_epoch (= settle_epoch_rip200), which had no UTXO code at all.
Rehearsed on a snapshot of the live DB with dual-write on: accounts were
credited 1,500,000 uRTC and ZERO boxes were minted, i.e. the UTXO model
falls a whole epoch pot (1.5 RTC) behind every epoch. Safe direction
(mirror <= balance still holds) but /utxo/integrity would report the
models disagreeing, growing daily.

Mint from epoch_rewards, which BOTH settlement branches write, and hook
both: the standard path and the anti-double-mining path, which returns
early and is the branch production actually takes (rehearsal: ADM).
Values come from the credited uRTC x100, never re-truncated (#8481), and
each box is registered as an account mirror, joined on the mining_reward
tx so a user's /utxo/transfer box at the same height is never tagged
(#8394). Failure raises -> the caller rolls back -> the epoch stays
unsettled, rather than committing a half-mirrored settlement.

utxo_db.apply_transaction: set (and restore) row_factory on a caller
connection. settle_epoch_rip200 opens a plain sqlite3.connect, and the
reads inside apply_transaction use row['col'] -> TypeError. Found by the
new tests, not in review.

Rehearsal after the fix: ADM path minted 8 boxes = exactly the 150,000,000
nRTC credited, all mirrored, 0 mirror>balance, 0 unmirrored.
0 regressions (4216 -> 4219); fetchall guard 11/11.

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.

3 participants