Skip to content

Multisig hub catch-up panics when the operation's change UTXO was spent while the wallet was behind - #102

Open
nklyy wants to merge 1 commit into
RGB-Tools:masterfrom
UTEXO-Protocol:test/hub-replay-spent-change
Open

Multisig hub catch-up panics when the operation's change UTXO was spent while the wallet was behind#102
nklyy wants to merge 1 commit into
RGB-Tools:masterfrom
UTEXO-Protocol:test/hub-replay-spent-change

Conversation

@nklyy

@nklyy nklyy commented Aug 17, 2026

Copy link
Copy Markdown

Summary

A multisig cosigner catching up on hub history panics in get_change_utxo_idx when the replayed operation assigned its RGB change to a UTXO that was created and spent while that cosigner was not syncing:

thread '…' panicked at src/wallet/online.rs:2934:36: should exist

Through FFI bindings the panic aborts the host process, and since operations are processed sequentially the same operation is replayed on every restart: the wallet crash-loops with no recovery short of manual DB surgery. We hit this in production-like use (a wallet restored from an older copy catching up on hub history) on v0.3.0-beta.28; master panics identically - the test in this PR is red on current master.

Mechanism

get_change_utxo_idx treats its two arms asymmetrically:

  • the btc_change arm tolerates a missing TXO and inserts a placeholder row (exists: false);
  • the change_utxo_outpoint arm does a bare txn.get_txo(outpoint)?.expect("should exist") (online.rs:2934).

Nothing guarantees that row exists:

  • every regular sync writes TXO rows with include_spent = false - unspent only - so an outpoint created and spent inside the wallet's offline window is never backfilled;
  • the spent-assignments loops in save_transfers have a rescue path (FastSync + include_spent = true) for missing input TXOs, but the change outpoint is looked up without any rescue;
  • a create-utxos replay materializes no TXO rows at all.

A wallet that never synced is silently rescued - its missing inputs trigger the spent-assignments backfill, which happens to also insert the change TXO. A wallet that was merely behind (the realistic recovery case: it knows the inputs from before it went dark, so the rescue never fires) panics.

Reproducer

sync_with_hub_replay_survives_inflation_change (2-of-3 multisig, IFA):

  1. create-utxos round 1: exactly 2 outputs - the genesis occupies both;
  2. issue IFA (600 fungible + 1000 inflation rights);
  3. the lagging cosigner syncs up to here, then goes dark;
  4. create-utxos round 2 - now the only empty colorable outputs;
  5. inflation 1: rights change lands on a round-2 output, btc_change: None (1000-sat UTXOs make the BTC remainder sub-dust);
  6. inflation 2: spends inflation 1's rights-change UTXO;
  7. the lagging cosigner catches up → replaying inflation 1 panics.

At the moment of the panic the lagging wallet's txo table holds the still-unspent siblings of the change tx but not the spent change target - the exact pattern we found in the live incident's wallet DB.

Suggested direction

Mirroring the spent-assignments rescue in the change_utxo_outpoint arm (on a miss: sync_wallet(FastSync, include_spent = true), then retry the lookup) makes this test pass end-to-end on our fork, with the rest of the multisig suite unaffected. Not included here on purpose - this looks like another instance of the "every hub operation should have a deterministic outcome, identical for all cosigners" bucket discussed in #94: here the outcome depends on the cosigner's local txo table state. Happy to adapt the test or the fix to whatever shape that wider rework takes.

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