From 38bfeeecdafb678d2a495c2865d1ff4dd6255ac9 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 22 Aug 2026 08:04:06 -0700 Subject: confirm: never re-credit receive outputs on wallet-funded txs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A transaction our own wallet contributed inputs to carries a "send" detail; every receive output on it is then our own change or an internal move, never an external deposit. Crediting such an output mints phantom balance against funds already in custody — the misclassification an external report flagged in the on-chain send path (wallet ownership is not account ownership). Guard the credit at the settlement layer, which is the correct boundary and covers both bitcoin and liquid uniformly: if the tx has any send detail, skip every receive output (log a tripwire for amounts >= 300 sats so continued probing is visible). Rejecting these at send time instead would break legitimate withdrawals, whose change routinely lands on a reused deposit address that resolves to an invoice. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XiwgmQKwKnRxUmVDd4xSf7 --- routes/payments.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/routes/payments.ts b/routes/payments.ts index 4f0e7f6e..f8a8adba 100644 --- a/routes/payments.ts +++ b/routes/payments.ts @@ -488,6 +488,16 @@ export default { let aid; if (!hot) aid = wallet; + // Change-recredit guard (2026-08-18). If our own wallet contributed inputs + // to this transaction, a "send" detail is present. In that case every + // receive output is our own CHANGE or an internal move — never an external + // deposit — so it must not be credited: doing so mints phantom balance + // (the withdrawal-change re-credit exploit that drove the L-C creep). A + // genuine coinos->coinos transfer is settled off-chain in the send path + // (credit() resolves getInvoice(hash) and books an internal transfer), so + // an on-chain receive funded by our own spend is only ever change. + const weSpent = details.some((d: any) => d.category === "send"); + for (const { address, amount, asset, category, vout } of details) { if (!address) continue; if (type === PaymentType.liquid && asset !== config.liquid.btc) @@ -509,6 +519,16 @@ export default { continue; } + // See weSpent above: a receive output on a transaction our wallet funded + // is change, not a deposit. Skip it so we never re-credit our own funds. + // Log the blocked amount as a tripwire: sums the phantom credit prevented + // and surfaces any continued exploitation attempts. + if (weSpent) { + if (sats(amount) >= 300) + warn("blocked change re-credit", txid, vout, sats(amount), address); + continue; + } + const p = await getPayment(`${txid}:${vout}`); if (!p) { -- cgit v1.2.3