aboutsummaryrefslogtreecommitdiffstats
path: root/src/app.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-28 09:57:18 -0700
committerAdam Soltys <[email protected]>2026-06-28 09:57:18 -0700
commit7dd7f1678c9f294b7a42bfbca263c53c08a83ac4 (patch)
tree96ebd94f304e0e80f44c5a2af738e53538164877 /src/app.js
parent1e43db6e50e5e1dc7ccb8fb53e7ce68cc580967d (diff)
downloadcoinosv3-7dd7f1678c9f294b7a42bfbca263c53c08a83ac4.tar.gz
coinosv3-7dd7f1678c9f294b7a42bfbca263c53c08a83ac4.zip
Consolidate coins: a Settings action + a gift-time recommendation
- Settings gains a 'Consolidate coins' card (shown when you hold >1 spendable coin): wallet.consolidate() does a max self-send merging them into one, so future sends/gifts use a single input — cheaper and a smaller gift QR. - At gift creation, detect when the gift would bundle several coins (giftCoinSummary returns the input count) and trigger the split/consolidate offer when count ≥ 3 (not just when it frees change). The offer now explains it plainly ('would bundle N coins, costly to claim') and recommends splitting into one coin first, with 'Use N coins anyway' as the explicit opt-out. Verified on regtest: 6 small coins → a 1000-sat gift needs 3 inputs and triggers the offer; consolidate merges them to 1 coin, after which the same gift is a clean single input. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Diffstat (limited to 'src/app.js')
-rw-r--r--src/app.js56
1 files changed, 45 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'))
)
);