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
+5 -2
View File
@@ -308,9 +308,12 @@ async function handleWallets(ctx: OfficerContext, seg: string[]): Promise<Respon
if (!req2.sendAll && !req2.amountSats) return badRequest('amountSats or sendAll is required');
if (!req2.satPerVbyte || req2.satPerVbyte < 1) return badRequest('satPerVbyte must be at least 1');
// Never spend a frozen coin, even if the caller passed no explicit outpoint list.
const frozen = new Set(await getFrozenOutpoints(walletId));
const frozenList = await getFrozenOutpoints(walletId);
const frozen = new Set(frozenList);
if (req2.outpoints?.some((o) => frozen.has(o))) return badRequest('refusing to spend a frozen UTXO');
const result = await backend.sendCoins(req2);
// Spread last so a caller cannot supply its own frozen list: the check above only ever covered
// outpoints the caller named explicitly, which left automatic selection free to pick a frozen coin.
const result = await backend.sendCoins({ ...req2, frozenOutpoints: frozenList });
if (req2.label) await setWalletLabel(walletId, 'tx', result.txid, req2.label);
return json(result);
}