diff options
| -rw-r--r-- | src/app.js | 56 | ||||
| -rw-r--r-- | src/i18n.js | 8 | ||||
| -rw-r--r-- | src/wallet.js | 23 |
3 files changed, 76 insertions, 11 deletions
@@ -56,6 +56,7 @@ const ui = { giftDmStatus: null, // 'sending' | 'sent' | 'failed' — nostr DM of the claim code claimLocked: null, // a locked gift being opened: { v, amount, to, ct } claimCodeInput: '', // the claim code the recipient pastes from their nostr DM + consolidateError: null, // error from a coin-consolidation attempt giftCode: null, // last-created gift PSBT code giftError: '', giftMax: false, // gift the whole spendable balance (no-change sweep) @@ -1428,12 +1429,40 @@ function settingsTab() { : h('button', { onClick: () => { ui.addrScan = true; ui.addrScanPage = 0; render(); } }, t('rescanAddresses')) ), networkCard(), + consolidateCard(), explorerCard(), spIndexerCard(), wallet.watchOnly || !wallet.mnemonic ? null : syncCard() ); } +// Merge many small coins into one. Useful after lots of small receives/gifts — +// keeps future sends/gifts to a single input (cheaper, smaller gift QR). +function consolidateCard() { + if (wallet.watchOnly) return null; + const n = wallet.spendableCoinCount ? wallet.spendableCoinCount() : 0; + if (n < 2) return null; + return h('div', { class: 'card col' }, + h('h3', {}, t('consolidateTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, t('consolidateDesc', { n })), + ui.consolidateError && h('div', { class: 'notice err' }, ui.consolidateError), + h('button', { class: 'btn-block', disabled: ui.busy || wallet.offline, onClick: doConsolidate }, ui.busy ? h('span', { class: 'spinner' }) : t('consolidateAction', { n }))); +} +async function doConsolidate() { + if (wallet.offline) { ui.consolidateError = t('scanOffline'); render(); return; } + ui.busy = true; ui.consolidateError = ''; render(); + try { + const rate = (wallet.feeRates && wallet.feeRates.halfHourFee) || 5; + await wallet.consolidate(rate); + toast(t('consolidateDone')); + wallet.scan().catch(() => {}); + } catch (e) { + ui.consolidateError = e.message || t('consolidateFailed'); + } + ui.busy = false; + render(); +} + // Gift link: presign a chosen amount as a #gift= PSBT that whoever opens claims // into a fresh wallet only they control. The coin is reserved until claimed. @@ -1454,20 +1483,22 @@ function createGiftLink() { // A specific-amount gift must leave us a dust change output (the gift PSBT // commits one). If the amount is so close to the balance that no dust change // is possible, point the user at Max to gift the whole balance instead. - const lock = wallet.giftLockPreview(amt); - if (lock == null) { + const summary = wallet.giftCoinSummary(amt); + if (summary == null) { ui.giftError = t('giftNeedsHeadroom', { n: fmtAmount(Math.max(0, spendable - 294)) + ' ' + unitLabel() }); render(); return; } - // Creating the gift now locks the whole source coin until it's claimed. If - // best-fit can't find a near-exact coin, a pre-split would shrink the locked - // change down to a dust pad (294) — worth offering only when the liquidity it - // frees exceeds the split's own fee (otherwise you'd pay more than you regain). + const lock = summary.lock; + // Offer to split/consolidate into one coin first when it would free meaningful + // change OR when the gift would otherwise bundle several coins — a many-input + // gift is costly to claim (each input adds to the claimer's fee) and its link + // can outgrow a QR. Splitting absorbs the fragmentation into the funding tx and + // leaves the claimer a clean single-input gift. const splitFee = Math.ceil((11 + 68 + 31 * 2) * Math.max(1, Math.round(rate))); const freed = lock - amt - 294; - if (freed > splitFee && !wallet.offline) { - ui.giftSplitOffer = { amt, lock, freed, fee: splitFee }; + if ((freed > splitFee || summary.count >= 3) && !wallet.offline) { + ui.giftSplitOffer = { amt, lock, freed: Math.max(0, freed), fee: splitFee, count: summary.count }; ui.giftError = ''; render(); return; @@ -1566,15 +1597,18 @@ function giftCard() { ? (() => { const o = ui.giftSplitOffer; const u = ' ' + unitLabel(); + const manyCoins = (o.count || 0) >= 3; return h('div', { class: 'col gap6' }, h('div', { class: 'small muted' }, - t('giftSplitExplain', { lock: fmtAmount(o.lock) + u, change: fmtAmount(o.lock - o.amt) + u, fee: fmtAmount(o.fee) + u })), + manyCoins + ? t('giftConsolidateExplain', { count: o.count, fee: fmtAmount(o.fee) + u }) + : t('giftSplitExplain', { lock: fmtAmount(o.lock) + u, change: fmtAmount(o.lock - o.amt) + u, fee: fmtAmount(o.fee) + u })), ui.giftError && h('div', { class: 'notice err' }, ui.giftError), ui.busy ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' })) : h('div', { class: 'col gap6' }, - h('button', { class: 'btn-primary btn-block', onClick: () => doSplitForGift(o.amt) }, t('giftSplitFirst')), - h('button', { class: 'btn-block', onClick: () => doCreateGift(o.amt, giftRate()) }, t('giftLockWhole', { n: fmtAmount(o.lock) + u })), + h('button', { class: 'btn-primary btn-block', onClick: () => doSplitForGift(o.amt) }, manyCoins ? t('giftConsolidateFirst') : t('giftSplitFirst')), + h('button', { class: 'btn-block', onClick: () => doCreateGift(o.amt, giftRate()) }, manyCoins ? t('giftUseManyCoins', { count: o.count }) : t('giftLockWhole', { n: fmtAmount(o.lock) + u })), h('button', { class: 'linklike small', style: 'align-self:center', onClick: () => { ui.giftSplitOffer = null; render(); } }, t('back')) ) ); diff --git a/src/i18n.js b/src/i18n.js index 66f866d..3562337 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -241,6 +241,14 @@ const STR = { giftDmFailed: 'Couldn’t send the nostr DM. Share this code with them privately:', giftCopyCode: 'Copy claim code', giftQrTooLong: 'Too large for a QR code — share the link below instead.', + giftConsolidateExplain: 'This gift would bundle {count} coins, making it costly for the recipient to claim. Splitting one coin first (~{fee}) gives them a clean single-input gift.', + giftConsolidateFirst: 'Split into one coin first', + giftUseManyCoins: 'Use {count} coins anyway', + consolidateTitle: 'Consolidate coins', + consolidateDesc: 'Your balance is spread across {n} coins. Merging them into one makes future sends and gifts cheaper and smaller.', + consolidateAction: 'Merge {n} coins into one', + consolidateDone: 'Consolidating — your coins are merging into one.', + consolidateFailed: 'Consolidation failed.', claimWithExtension: 'Claim with nostr extension', orEnterCode: 'or paste the claim code', extWrongAccount: 'Your extension is signed in as a different nostr account than this gift is for.', diff --git a/src/wallet.js b/src/wallet.js index e2b5b35..6e3ea81 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -1577,6 +1577,29 @@ export class Wallet { return sel ? sel.reduce((s, u) => s + u.value, 0) : null; } + // The coins a gift of this amount would lock: total value + how many inputs. + // A high input count means a costly-to-claim gift (each input adds to the + // claimer's fee) — the UI uses it to recommend splitting/consolidating first. + giftCoinSummary(amountSats) { + const sel = this._selectGiftCoins(BigInt(Math.round(amountSats))); + return sel ? { lock: sel.reduce((s, u) => s + u.value, 0), count: sel.length } : null; + } + + // Number of unreserved spendable coins (for the consolidate prompt/action). + spendableCoinCount() { + return this.utxos.filter((u) => !this.isReserved(utxoId(u)) && (u.confirmed || u.chain === 1)).length; + } + + // Merge all unreserved spendable coins into one, via a max self-send. Leaves + // the wallet with a single coin so future sends/gifts use one input. + async consolidate(feeRate) { + const rate = Math.max(1, Math.round(feeRate)); + const draft = this.buildTx({ recipients: [{ address: this.freshReceive().address }], feeRate: rate, sendMax: true }); + const hex = this.sign(draft.tx); + await this.broadcast(hex); + return { txid: draft.tx.id, inputs: draft.inputsCount }; + } + // Build a signed gift from already-chosen coins. If the gift leaves a viable // (>= dust) change, output0 is that change back to us, cryptographically fixed // by signing input0 SIGHASH_SINGLE (the rest SIGHASH_NONE) — so our change is |
