aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app.js56
-rw-r--r--src/i18n.js8
-rw-r--r--src/wallet.js23
3 files changed, 76 insertions, 11 deletions
diff --git a/src/app.js b/src/app.js
index f069830..d9066a3 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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