Skip to content

sync_with_hub panics replaying an operation whose change UTXO was spent while the wallet was behind #87

Description

@nklyy

What happened

A multisig cosigner catching up on hub history panics in get_change_utxo_idx; the panic crosses the FFI boundary and aborts the host process:

INFO[multisig.rs:2051] operation 7 tx b67b9ed5… already broadcast; completing without re-finalizing
PANIC: Location { file: "src/wallet/online.rs", line: 2676 } - should exist

The failing operation is replayed again on every restart, so the wallet ends up in a crash loop with no recovery short of manual DB surgery. Hit live on the bridge stand (2026-08-17, RGB connector wallet catching up after a restore) with v0.3.0-beta.28; dev has identical code in this path and panics on the same line.

Root cause

Operation 7's OperationData had btc_change: None and change_utxo_outpoint: e1ed6794…:2. The wallet's txo table contained vouts 1, 3, 4 and 5 of that tx - but not vout 2. Pattern: present = still unspent, missing = already spent. Vout 2 was created (by a create-utxos operation) and spent (as an input of a later operation) entirely inside the window the wallet was not syncing.

get_change_utxo_idx (src/wallet/online.rs:2642) 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").

Nothing guarantees that row exists:

  • every regular sync writes TXO rows via update_db_colored_txos_from_bdk(txn, include_spent = false) - unspent only, so a created-and-spent outpoint is never backfilled;
  • the spent-assignments loop in save_transfers has 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 backfill, which happens to also insert the change TXO); a wallet that was merely behind - the realistic recovery case - panics.

Reproducer

Test-only PR: . sync_with_hub_replay_survives_inflation_change, branch test/hub-replay-panic (off dev).

Anatomy (2-of-3 multisig, IFA with inflation rights):

  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 forced onto 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. catch-up replays inflation 1 → panic at online.rs:2676, byte-for-byte the live failure, including the txo-table pattern (unspent siblings present, the spent change target missing).

Proposed fix (verified locally, not included in the PR)

Mirror the spent-assignments rescue in the change_utxo_outpoint arm: on a miss run sync_wallet(FastSync, include_spent = true) and retry the lookup. With that patch the reproducer passes end-to-end (651/651/651 balances on all three cosigners) and the rest of the multisig suite is unaffected (4/4).

Side note on the multisig test suite

The pinned hub image ghcr.io/rgb-tools/rgb-multisig-hub:0.1.1 (2026-05-01) predates biscuit-auth 6.0.0 and rejects every token minted by current test code, so all multisig tests fail with "Missing or invalid credentials" unless the hub is built from current source. CI never notices because it runs with --no-default-features.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions