fix: don't panic the net task when an M3 lands late (#146) - #147
fix: don't panic the net task when an M3 lands late (#146)#147rsantacroce wants to merge 2 commits into
Conversation
`connect_withdrawal_bundle_submitted` asserted that a bundle's
`WithdrawalBundleSubmitted` event arrives exactly one sidechain block after
the block that created the bundle:
assert_eq!(bundle_block_height, block_height - 1);
That only holds if the M3 reaches the mainchain in the immediately following
block. An M3 is a coinbase message written by the block producer from its own
enforcer's database, so it reaches the chain only when the sidechain operator
themself mines a mainchain block. For an operator who is not the dominant
miner that is an arbitrary number of blocks later, and the assert fires.
The failure is silent and permanent. The assert panics a tokio worker; the
process stays up and systemd still reports the unit active, but the net task
is gone. The node then has zero peers, never connects another tip, and keeps
winning BMM bids for sidechain blocks it drops. Observed on eCash alphanet
slot 255: bundle created at height 11, M3 mined at height 25, node stuck for
two days across ~100 mainchain commitments.
`bundle_block_height` is not read anywhere else in the function, so the
mismatch is now logged as a warning and the submission is applied normally.
Using `saturating_sub` also removes the underflow panic at height 0.
The inverse path, `disconnect_withdrawal_bundle_submitted`, reconstructs the
creation height as `bundle_status.height - 1` and carries the same underflow.
That is made saturating too. Its reconstruction is still only exact when the
M3 was prompt; fixing that needs the real creation height persisted, which
changes the `withdrawal_bundles` record format, so it is documented in place
rather than folded into a panic fix.
Two regression tests, both of which panic against the previous code with the
exact assertion and overflow from the report.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011UwnSESebfdHQkLs2arAEt
…146) Removing the assert in the previous commit exposed the rest of the one-block assumption, which lived on both sides of the connect/disconnect pair. `disconnect_withdrawal_bundle_submitted` restored the pending bundle with `bundle_status.height - 1`, reconstructing the creation height as "the block before the submission". That is only right when the M3 was prompt, which is exactly what #146 shows it need not be. The height had nowhere else to live: `pending_withdrawal_bundle` carries it, and that record is deleted when the submission is connected. Record it instead, in a new `withdrawal_bundle_creation_heights` database keyed by m6id. A separate database rather than a field on the `withdrawal_bundles` record so databases written by earlier versions keep deserializing; a missing entry falls back to the old guess with a warning. The consumer of that height was also wrong, independently of the M3 timing. `connect` stores the collected bundle keyed by the height it ran at, and `disconnect` reads the same height for the same block -- `connect` runs after the block is connected, `disconnect` before the tip is rolled back. The cleanup compared it against `block_height - 1`, which no block ever matched, so a bundle collected by a disconnected block stayed pending on the chain it had been reorged off. Its failure-gap comparison was `>` where the collection in `connect` uses `>=`, so the two also disagreed at exactly the gap. Three tests. Two fail against the previous logic: the restore comes back with 24 instead of 11, and the cleanup never fires. The third pins that a bundle collected by an earlier block is still left alone. Not addressed here: the `assert_eq!(block_height - 1, ...)` pair guarding the deposit and withdrawal-bundle-event block records in `disconnect` carries the same off-by-one and the same panic-on-a-worker-thread hazard. Changing those needs a reorg fixture to validate rather than a comparison read, so they are left for their own change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UwnSESebfdHQkLs2arAEt
|
Thanks for turning this around so fast — and for chasing the assumption past the Up front, so you can weigh it accordingly: I'm not an experienced developer — Claude's reviewThe fix is correct, including the part flagged as the least certain inference. 1. The connect/disconnect height symmetry is confirmed in the callerThe PR justifies
Both sides see N for the same block. 2. The two remaining asserts don't need a reorg fixtureThe PR leaves these for their own change, because validating them looked like it
connect stores N. disconnect compares N-1. The Same failure mode as #146: These arguably belong in this PR rather than a follow-up, because this PR is what 3. The legacy fallback can be exact rather than a guessThe new .unwrap_or_else(|| bundle_status.height.saturating_sub(1))reproduces the #146 bug for precisely the databases that hit #146. The alphanet That's recoverable, because the bundle commits to its own creation height. hash([spend_utxos.keys()..., OutPoint::Regular { txid: [0; 32], vout: block_height }])and stores it as an If you'd rather keep the PR tight, a 4. Minor, same family
5. From my nodeThe patched connect path has been running on slot 255 since 2026-09-01 — live The bundle applied cleanly and the chain advanced 25 → 26, its first new block Three corrections and notes to the record while I'm here:
As above, I can bring the node up and run any patch against it — it's the only |
Fixes #146.
The reported bug
connect_withdrawal_bundle_submittedasserted that a bundle'sWithdrawalBundleSubmittedevent arrives exactly one sidechain block after the block that created it:@Coinelius's diagnosis in #146 is correct and the reasoning holds up: an M3 is a coinbase message, written by the block producer from its own enforcer's database, so it reaches the mainchain only when the sidechain operator themself mines a mainchain block. For an operator who is not the dominant miner that is an arbitrary number of blocks later, and the assert fires — killing the net task while the process stays up and the unit still reports
active.bundle_block_heightis read nowhere else in the function, so the mismatch is now a warning and the submission is applied normally.saturating_subalso removes the height-0 underflow.What the fix uncovered
Removing the assert exposed the rest of the same assumption, on both sides of the connect/disconnect pair. Neither was reachable before, because the assert killed the node first.
1. The inverse path guessed the creation height.
disconnect_withdrawal_bundle_submittedrestored the pending bundle withbundle_status.height - 1— the same "M3 was prompt" assumption. The height had nowhere else to live:pending_withdrawal_bundlecarries it, and that record is deleted when the submission is connected.It is now recorded in a new
withdrawal_bundle_creation_heightsdatabase keyed by m6id. A separate database rather than a field on thewithdrawal_bundlesrecord, so databases written by earlier versions keep working — a missing entry falls back to the old guess with a warning.NUM_DBSgoes 17 → 18.2. The consumer of that height was wrong regardless of M3 timing.
connectstores the collected bundle keyed by the height it ran at;disconnectreads the same height for the same block (connectruns after the block is connected,disconnectbefore the tip is rolled back — theexpired_swapsround trip relies on exactly this and its test passes). The cleanup compared againstblock_height - 1, which no block ever matched, so a bundle collected by a disconnected block stayed pending on the chain it had been reorged off. Its failure-gap comparison was also>where collection inconnectuses>=, so the two disagreed at exactlyWITHDRAWAL_BUNDLE_FAILURE_GAP.Tests
Five new tests in
lib/state/two_way_peg_data.rs, using the heights from the report (bundle at 11, M3 at 25). Four fail against the previous code:delayed_bundle_submission_applies_instead_of_panickingassertion left == right failed,left: 11,right: 24bundle_submission_at_height_zero_does_not_underflowattempt to subtract with overflowdisconnecting_a_late_submission_restores_the_true_creation_heightdisconnect_drops_the_bundle_collected_by_that_blockdisconnect_keeps_a_bundle_collected_by_an_earlier_blockcargo test --workspace59 passed / 0 failed (was 54).cargo fmt --check,cargo clippy --all-targets --all-features, andcargo check --workspace --all-targetsare all clean — the remaining clippy warnings are the pre-existingheed::EnvFlags::NO_TLSdeprecations inlib/node/mod.rsandlib/wallet.rs.Deliberately not in this PR
disconnecthas two moreassert_eq!(block_height - 1, ...)guards, on the deposit and withdrawal-bundle-event block records, with the same off-by-one and the same panic-on-a-worker-thread hazard. By the reasoning above they look wrong too, but confirming that needs a reorg fixture with real deposit events rather than a comparison read, and I would rather not bundle an unvalidated consensus-path change into a panic fix. Happy to take it on as a follow-up.🤖 Generated with Claude Code
https://claude.ai/code/session_011UwnSESebfdHQkLs2arAEt