diff options
| author | Adam Soltys <[email protected]> | 2026-06-24 20:40:38 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-24 20:40:38 -0700 |
| commit | ae62886bb3a7fe72a3828089c58205662696bca9 (patch) | |
| tree | b31d8e07e6cae91db5ed2057b3d5f2c22002ec07 /src/wallet.js | |
| parent | a6bfb5bd4429cf7069a5d0c5e61cb5873ddd8230 (diff) | |
| download | coinosv3-ae62886bb3a7fe72a3828089c58205662696bca9.tar.gz coinosv3-ae62886bb3a7fe72a3828089c58205662696bca9.zip | |
Minimize gift lock-up: best-fit coins + optional pre-split
Gift coin selection now picks the smallest single coin that covers the gift
(was largest-first), locking the least value until claim. When no near-exact
coin exists, offer to pre-split: broadcast a self-send carving out a gift+dust
coin so the gift later locks only ~the gift amount.
The split is only offered when the liquidity it frees (lock - gift - dust)
exceeds the split's own fee, so it never costs more than it regains. Decision
is surfaced as an interstitial on Create, since a full re-render drops input
focus and a live preview isn't viable.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Diffstat (limited to 'src/wallet.js')
| -rw-r--r-- | src/wallet.js | 64 |
1 files changed, 47 insertions, 17 deletions
diff --git a/src/wallet.js b/src/wallet.js index 2871c5e..eeb901f 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -881,31 +881,49 @@ export class Wallet { })); } + // Best-fit coin selection for a gift of `gift` sats (needs gift + a dust + // change): the smallest single confirmed coin that covers it — one input and + // the least value locked until claim — else the fewest large coins. Returns + // the selected UTXOs, or null if confirmed funds are insufficient. + _selectGiftCoins(gift) { + const DUST = 294n; + const need = gift + DUST; + const avail = this.utxos.filter((u) => u.confirmed && !this.isReserved(utxoId(u))); + const single = avail.filter((u) => BigInt(u.value) >= need).sort((a, b) => a.value - b.value)[0]; + if (single) return [single]; + const sel = []; + let sum = 0n; + for (const u of [...avail].sort((a, b) => b.value - a.value)) { + sel.push(u); + sum += BigInt(u.value); + if (sum >= need) return sel; + } + return null; // insufficient confirmed funds + } + + // Sats that creating this gift right now would lock (the selected coins' + // total), or null if confirmed funds are insufficient. Lets the UI warn when + // the lock would dwarf the gift and offer to split a coin first. + giftLockPreview(amountSats) { + const sel = this._selectGiftCoins(BigInt(Math.round(amountSats))); + return sel ? sel.reduce((s, u) => s + u.value, 0) : null; + } + // Build a SIGHASH_SINGLE-signed gift: input = a single coin, output0 = change // back to us (cryptographically fixed), leaving the rest for the claimer to // direct to their own fresh address. Reserves the coin. Returns { code, ... }. createGift(amountSats, feeRate) { const gift = BigInt(Math.round(amountSats)); const rate = Math.max(1, Math.round(feeRate)); - const DUST = 294n; if (gift < BigInt(giftMinimum(rate))) throw new Error('Gift amount is too small.'); - // Select confirmed coins (largest first, to minimize inputs) until they - // cover the gift plus a dust change. The change output goes back to us and - // is protected; the rest — the full gift amount — is the claimer's to - // direct. No fee is reserved here: the claimer's wallet looks up the fee - // rate at claim time and subtracts it from this amount. - const pool = this.utxos - .filter((u) => u.confirmed && !this.isReserved(utxoId(u))) - .sort((a, b) => b.value - a.value); - const sel = []; - let sum = 0n; - for (const u of pool) { - sel.push(u); - sum += BigInt(u.value); - if (sum >= gift + DUST) break; - } - if (sum < gift + DUST) throw new Error('Not enough confirmed funds for that gift amount.'); + // Best-fit selection locks the least value; the change output goes back to + // us and is protected, while the rest — the full gift amount — is the + // claimer's to direct. No fee is reserved here: the claimer's wallet looks + // up the fee rate at claim time and subtracts it from this amount. + const sel = this._selectGiftCoins(gift); + if (!sel) throw new Error('Not enough confirmed funds for that gift amount.'); + const sum = sel.reduce((s, u) => s + BigInt(u.value), 0n); const change = sum - gift; // back to us, >= DUST by construction const t = new btc.Transaction({ allowUnknownOutputs: true }); @@ -924,6 +942,18 @@ export class Wallet { return { code: base64urlnopad.encode(t.toPSBT()), amount: Number(gift), reserved: sel.map(utxoId) }; } + // Pre-split for a gift: broadcast a normal self-send that carves out a coin of + // exactly gift + dust to one of our own addresses, with the rest returned as + // change. Once it confirms, createGift's best-fit picks that coin and locks + // only ~the gift amount instead of a large coin. Costs an on-chain fee now. + async splitForGift(amountSats, feeRate) { + const DUST = 294; + const target = Math.round(amountSats) + DUST; + const draft = this.buildTx({ recipients: [{ address: this.freshReceive().address, amount: target }], feeRate }); + const hexTx = this.sign(draft.tx); + return await this.broadcast(hexTx); + } + // --- realtime (mempool.space WebSocket) --------------------------------- // Pushes us new mempool/confirmed transactions for our addresses so history // and balances update with no polling. |
