honour frozen coins in automatic selection, not just coin control

freezing a coin promised it would not be spent, and the promise only
held when the caller named outpoints explicitly. an ordinary send picked
its own inputs from a snapshot in which every utxo said frozen: false —
the chain has no idea what officer froze — so selectCoins, which has
always filtered on that flag, never saw one set. sendAll was the worst
case: "send everything" swept the frozen coin too.

the frozen list now travels with the request, set by the route and
overwritten if a caller supplies one. it can only ever restrict what is
spendable, so smuggling a value in gains nothing. the backend still
reads no officer table.

three tests pin it, including a control that sends successfully once the
coin is unfrozen — without it the other two would pass on a wallet that
could not spend at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 20:25:49 +00:00
co-authored by Claude Opus 5
parent 1d9a648ff7
commit bc06fbb2a5
4 changed files with 77 additions and 6 deletions
@@ -585,8 +585,16 @@ export class OnchainBackend extends BaseBackend {
// that may already be gone, and the failure would arrive as a broadcast rejection after signing.
const snap = await this.snapshot({ fresh: true });
// The snapshot comes off the chain, which knows nothing about freezing, so every UTXO in it claims
// `frozen: false`. selectCoins has always honoured the flag — nothing ever set it, which is what made
// a plain send happily spend a coin the owner had frozen, and made "send everything" sweep it.
const frozen = new Set(req.frozenOutpoints ?? []);
const utxos = frozen.size
? snap.utxos.map((u) => (frozen.has(`${u.txid}:${u.vout}`) ? { ...u, frozen: true } : u))
: snap.utxos;
const selection = selectCoins({
utxos: snap.utxos,
utxos,
targetSats: req.amountSats ?? 0,
sendAll: req.sendAll === true,
satPerVbyte: req.satPerVbyte,