fix(bifrost/utxo): rebuild unsigned tx when keysign party never forms

Problem

On mainnet, BTC vaults signed no outbounds for ~2h while the outbound queue grew past 400.

When vault members build different UTXO txs for the same outbound (their bitcoind views of the vault's UTXOs differ), they submit different message sets. go-tss derives the msgID from the sorted messages (bifrost/tss/go-tss/tss/tss.go), and the party leader from the msgID (bifrost/p2p/leader_provider.go), so each member waits on a different leader and all fail join-party with TssSyncFail. Each member then checkpoints its own divergent tx and reuses it on every retry, so the split cannot heal until THORChain reschedules the outbound. UTXO signing is serialised per vault and chain, so one split item blocks the whole vault.

Fix

The checkpoint exists so a vault never signs two different txs for one outbound. That only matters once signing may have produced a signature. A join-party failure (TssSyncFail with no round) returns before SignMessage runs, so no share can exist.

When every input of an attempt with no prior checkpoint failed that way, skip the checkpoint and let the next attempt rebuild the tx from current chain state. Any other failure, including partial signing, a round failure, or an item that is already checkpointed, keeps today's behaviour. This is narrower than the existing not-selected path, which already drops the checkpoint.

Observability

bifrost_utxo_keysign_party_not_formed_total{chain} increments each time the unsigned tx is discarded, next to the existing warn log. If this rate stays high, members keep building divergent txs and the vault is not signing.

Safety notes

  • In non-test code, TssSyncFail is produced only on join-party paths (keysign/keygen/frost join, legacy leaderless join). Every post-join blame is TssTimeout, TssBrokenMsg or HashCheckFail with a round set.
  • If one member times out locally while its peers did join, that member never called SignMessage. The peers fail in a round and stay pinned. Signing two txs would need 2(t+1) > n distinct pinned members, which is the same argument the not-selected drop already relies on.
  • The checkpoint is written and cleared only under the pipeline vault/chain lock plus the utxo vault lock, so no other code path races it.

Testing

  • New unit tests (including the counter): bitcoin_signer_test.go, utxo_test.go, round_normalizer_test.go
  • go test -tags=mocknet ./bifrost/pkg/chainclients/utxo/... ./bifrost/tss/...: all packages pass
  • gofumpt and go vet are clean

Follow-up (pre-existing, out of scope)

tss_signer.go treats an empty-FailReason failure as "not selected", and signer.go drops the checkpoint for it. If SignMessage ever fails without calling SetBlame, a failure after join would already drop the checkpoint today.

🤖 Generated with Claude Code

Edited by Son of Odin

Merge request reports

Loading
Loading