diff options
| author | Adam Soltys <[email protected]> | 2026-06-24 20:55:01 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-24 20:55:01 -0700 |
| commit | 96e91a2cca47f4eae3514d6827a29354e507dadd (patch) | |
| tree | 34ff29ba4f0ee33b57b9a2f6d30474cab5f50c67 /src | |
| parent | ae62886bb3a7fe72a3828089c58205662696bca9 (diff) | |
| download | coinosv3-96e91a2cca47f4eae3514d6827a29354e507dadd.tar.gz coinosv3-96e91a2cca47f4eae3514d6827a29354e507dadd.zip | |
Instant gift-via-split and spend-unconfirmed support
Gift split no longer makes the sender wait for confirmation: createGiftFromSplit
broadcasts the carve-out, waits only until it's visible in the mempool, then
builds the gift spending that still-unconfirmed coin. Funding + claim form a
chain that confirms together; the recipient sees a pending balance until then.
Polls for mempool visibility before reserving so a background refresh can't drop
a not-yet-indexed coin and prune the reservation (double-spend risk).
Regular sends can already draw on unconfirmed coins; surface that honestly:
a 'pending' tag on unconfirmed coins in coin control, and a plain-language
warning at review when a payment spends unconfirmed bitcoin (won't confirm
until the ancestor does).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 26 | ||||
| -rw-r--r-- | src/i18n.js | 2 | ||||
| -rw-r--r-- | src/wallet.js | 106 |
3 files changed, 98 insertions, 36 deletions
@@ -50,7 +50,6 @@ const ui = { giftCode: null, // last-created gift PSBT code giftError: '', giftSplitOffer: null, // { amt, lock, freed, fee } when offering to split a coin first - giftSplitTxid: null, // txid of a broadcast pre-split, awaiting confirmation revokeId: null, // outpoint of a gift being revoked (confirm state) claimCode: null, // gift code being claimed (opened from a #gift= link) claimedAmount: 0, @@ -875,7 +874,6 @@ function lock() { ui.giftCode = null; ui.giftError = ''; ui.giftSplitOffer = null; - ui.giftSplitTxid = null; ui.revokeId = null; ui.receiveSeenIndex = null; ui.txDetail = null; @@ -1029,28 +1027,29 @@ function doCreateGift(amt, rate) { ui.giftCode = wallet.createGift(amt, rate).code; ui.giftError = ''; ui.giftSplitOffer = null; - ui.giftSplitTxid = null; } catch (e) { ui.giftError = e.message; ui.giftSplitOffer = null; } render(); } -// Pre-split: broadcast a self-send that carves out a right-sized coin so the -// later gift locks only ~the gift amount. The gift can be created once it -// confirms (best-fit then picks the carve-out automatically). +// Split + gift in one tap: broadcast a self-send carving out a right-sized +// coin, then immediately build the gift from that (unconfirmed) carve-out, so +// only ~the gift amount is locked and there's no confirmation wait. The link is +// ready right away; the recipient sees a pending balance until the split lands. async function doSplitForGift(amt) { if (wallet.offline) { ui.giftError = t('scanOffline'); render(); return; } ui.busy = true; ui.giftError = ''; render(); try { const rate = (wallet.feeRates && wallet.feeRates.halfHourFee) || 5; - ui.giftSplitTxid = await wallet.splitForGift(amt, rate); + ui.giftCode = (await wallet.createGiftFromSplit(amt, rate)).code; ui.giftSplitOffer = null; - wallet.scan().catch(() => {}); + ui.giftError = ''; } catch (e) { ui.giftError = e.message || t('giftSplitFailed'); } ui.busy = false; + wallet.scan().catch(() => {}); // reconcile the split from the mempool render(); } function giftCard() { @@ -1067,7 +1066,7 @@ function giftCard() { h('div', { class: 'addr-box break', style: 'width:100%;font-size:12px' }, giftUrl()), h('div', { class: 'row gap6 wrap' }, copyBtn(giftUrl(), t('copyLink')), - h('button', { class: 'btn-sm grow', onClick: () => { ui.giftCode = null; ui.giftAmount = ''; ui.giftSplitTxid = null; render(); } }, t('giftAnother')) + h('button', { class: 'btn-sm grow', onClick: () => { ui.giftCode = null; ui.giftAmount = ''; render(); } }, t('giftAnother')) ) ) : ui.giftSplitOffer @@ -1094,7 +1093,6 @@ function giftCard() { h('div', { style: 'display:flex;align-items:center' }, unitTag()) ), h('div', { class: 'small faint' }, t('giftMinNote', { n: fmtAmount(giftMinimum(giftRate())) + ' ' + unitLabel() })), - ui.giftSplitTxid ? h('div', { class: 'notice ok' }, t('giftSplitPending')) : null, ui.giftError && h('div', { class: 'notice err' }, ui.giftError), h('button', { class: 'btn-block', onClick: createGiftLink }, t('giftLinkReveal')) ), @@ -1625,7 +1623,7 @@ function giftView() { 'div', { class: 'col', style: 'gap:12px' }, giftCard(), - h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.giftMode = false; ui.giftCode = null; ui.giftError = ''; ui.giftSplitOffer = null; ui.giftSplitTxid = null; ui.revokeId = null; render(); } }, t('back')) + h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.giftMode = false; ui.giftCode = null; ui.giftError = ''; ui.giftSplitOffer = null; ui.revokeId = null; render(); } }, t('back')) ); } @@ -2012,7 +2010,8 @@ function coinControl() { onChange: (e) => { e.target.checked ? s.coins.add(id) : s.coins.delete(id); render(); }, }), h('div', { class: 'grow' }, - h('div', { class: 'mono small break' }, shortAddr(u.address, 14, 10)), + h('div', { class: 'mono small break' }, shortAddr(u.address, 14, 10), + !u.confirmed ? h('span', { class: 'tag pending', style: 'margin-left:6px' }, t('pendingTag')) : null), h('div', { class: 'path' }, `${u.chain}/${u.index} · ${shortTxid(u.txid)}:${u.vout}`) ), h('div', { class: 'amount small' }, fmtAmount(u.value)) @@ -2109,6 +2108,9 @@ function reviewView() { ) ), ui.sendError && h('div', { class: 'notice err' }, ui.sendError), + wallet.spendsUnconfirmed(d.tx) + ? h('div', { class: 'notice info' }, t('unconfirmedInputWarn')) + : null, wallet.offline ? h('div', { class: 'notice info' }, t('offlineSignNote')) : null, diff --git a/src/i18n.js b/src/i18n.js index a4f2180..f164b0b 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -141,6 +141,7 @@ const STR = { selectCoin: 'Select at least one coin to spend.', networkFee: 'Network fee', offlineSignNote: 'Offline: sign now, then broadcast the signed transaction from an online device.', + unconfirmedInputWarn: 'This payment spends bitcoin that hasn’t confirmed yet, so it won’t confirm until that earlier transaction does.', signBroadcast: 'Sign & broadcast', signTx: 'Sign transaction', signExport: 'Sign & export (broadcast elsewhere)', @@ -198,7 +199,6 @@ const STR = { giftSplitExplain: 'This gift would lock {lock} until it’s claimed ({change} of it comes back to you then). You can split a right-sized coin first for about {fee} so only the gift amount stays locked.', giftSplitFirst: 'Split a coin first', giftLockWhole: 'Lock the whole coin ({n})', - giftSplitPending: 'Coin split sent. Once it confirms, create your gift link — it’ll lock only the gift amount.', giftSplitFailed: 'Could not split the coin.', lockedInGifts: 'Locked in gifts', giftReserved: 'Unclaimed gift links ({n})', diff --git a/src/wallet.js b/src/wallet.js index eeb901f..d4418a5 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -678,6 +678,18 @@ export class Wallet { return summarize(sel, this.netCfg.net); } + // True if a built tx spends any of our still-unconfirmed coins — meaning it + // can't confirm until that parent (ancestor) transaction confirms first. + spendsUnconfirmed(tx) { + const pending = new Set(this.utxos.filter((u) => !u.confirmed).map((u) => utxoId(u))); + if (!pending.size) return false; + for (let i = 0; i < tx.inputsLength; i++) { + const inp = tx.getInput(i); + if (pending.has(`${hex.encode(inp.txid)}:${inp.index}`)) return true; + } + return false; + } + // Sign every input of an already-built Transaction with the matching HD key. sign(tx) { if (this.watchOnly) throw new Error('Watch-only wallet — no keys to sign with.'); @@ -909,22 +921,12 @@ export class Wallet { 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)); - if (gift < BigInt(giftMinimum(rate))) throw new Error('Gift amount is too small.'); - - // 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.'); + // Build a SIGHASH_SINGLE-signed gift from already-chosen coins: output0 = + // change back to us (cryptographically fixed), leaving the rest for the + // claimer to direct to their own fresh address. Reserves the coins, returns + // { code, amount, reserved }. Callers ensure sum(sel) >= gift + dust. + _buildGiftPsbt(sel, gift) { 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 }); // First input commits the change output (SIGHASH_SINGLE on output 0); the @@ -942,16 +944,74 @@ 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) { + // Build a gift link from a best-fit coin, locking the least value. No fee is + // reserved here: the claimer's wallet looks up the fee rate at claim time and + // subtracts it from the amount. + createGift(amountSats, feeRate) { + const gift = BigInt(Math.round(amountSats)); + const rate = Math.max(1, Math.round(feeRate)); + if (gift < BigInt(giftMinimum(rate))) throw new Error('Gift amount is too small.'); + const sel = this._selectGiftCoins(gift); + if (!sel) throw new Error('Not enough confirmed funds for that gift amount.'); + return this._buildGiftPsbt(sel, gift); + } + + // Split + gift in one shot, with no confirmation wait: broadcast a self-send + // that carves out a gift+dust coin, then build the gift spending that + // still-unconfirmed carve-out. The funding and the claim form a chain that + // confirms together; the recipient just sees a pending balance until the + // split confirms. Lets the sender lock only ~the gift amount (instead of a + // whole large coin) without waiting for the split to confirm first. + async createGiftFromSplit(amountSats, feeRate) { + const gift = Math.round(amountSats); + const rate = Math.max(1, Math.round(feeRate)); + if (BigInt(gift) < BigInt(giftMinimum(rate))) throw new Error('Gift amount is too small.'); const DUST = 294; - const target = Math.round(amountSats) + DUST; - const draft = this.buildTx({ recipients: [{ address: this.freshReceive().address, amount: target }], feeRate }); + const recvIndex = this.nextReceiveIndex; + const carveAddr = this.derive(0, recvIndex).address; + const changeIndex = this.nextChangeIndex; + + // Carve out exactly gift + dust to one of our own addresses; the rest comes + // back as change. buildTx already spends confirmed coins largest-first. + const draft = this.buildTx({ recipients: [{ address: carveAddr, amount: gift + DUST }], feeRate: rate }); + const carveIdx = draft.outputs.findIndex((o) => o.address === carveAddr); + if (carveIdx < 0) throw new Error('Could not size the split output.'); const hexTx = this.sign(draft.tx); - return await this.broadcast(hexTx); + const splitTxid = draft.tx.id; + await this.broadcast(hexTx); + + // Wait until the carve-out is actually visible in the mempool before we + // build the gift on top of it and reserve it. Otherwise a background refresh + // could rebuild the address from a not-yet-indexed API response, transiently + // drop the coin, and prune the reservation — risking a double-spend. + let seen = false; + for (let i = 0; i < 8 && !seen; i++) { + await sleep(i === 0 ? 400 : 900); + try { + const us = await this.api.addressUtxos(carveAddr); + seen = us.some((u) => u.txid === splitTxid && u.vout === carveIdx); + } catch {} + } + if (!seen) throw new Error('The split is taking a while to propagate. Once it confirms, create the gift from the new coin.'); + + // Reflect the split in our coin set — drop the spent inputs, add the + // (now mempool-visible) carve-out and change — so balances and gift tracking + // are correct immediately; a background scan reconciles the rest. + const spent = new Set(); + for (let i = 0; i < draft.tx.inputsLength; i++) { + const inp = draft.tx.getInput(i); + spent.add(`${hex.encode(inp.txid)}:${inp.index}`); + } + this.utxos = this.utxos.filter((u) => !spent.has(utxoId(u))); + this.utxos.push({ txid: splitTxid, vout: carveIdx, value: gift + DUST, address: carveAddr, chain: 0, index: recvIndex, confirmed: false }); + const changeIdx = draft.outputs.findIndex((o, i) => i !== carveIdx); + if (changeIdx >= 0) { + const co = draft.outputs[changeIdx]; + this.utxos.push({ txid: splitTxid, vout: changeIdx, value: co.amount, address: co.address, chain: 1, index: changeIndex, confirmed: false }); + } + this._recomputeBalanceFromUtxos(); + + return this._buildGiftPsbt([{ txid: splitTxid, vout: carveIdx, value: gift + DUST, chain: 0, index: recvIndex }], BigInt(gift)); } // --- realtime (mempool.space WebSocket) --------------------------------- |
