aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-24 20:40:38 -0700
committerAdam Soltys <[email protected]>2026-06-24 20:40:38 -0700
commitae62886bb3a7fe72a3828089c58205662696bca9 (patch)
treeb31d8e07e6cae91db5ed2057b3d5f2c22002ec07
parenta6bfb5bd4429cf7069a5d0c5e61cb5873ddd8230 (diff)
downloadcoinosv3-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]>
-rw-r--r--src/app.js62
-rw-r--r--src/i18n.js5
-rw-r--r--src/wallet.js64
3 files changed, 112 insertions, 19 deletions
diff --git a/src/app.js b/src/app.js
index ae21240..cae97d0 100644
--- a/src/app.js
+++ b/src/app.js
@@ -49,6 +49,8 @@ const ui = {
giftAmount: '', // gift-create amount input
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,
@@ -872,6 +874,8 @@ function lock() {
ui.giftAmount = '';
ui.giftCode = null;
ui.giftError = '';
+ ui.giftSplitOffer = null;
+ ui.giftSplitTxid = null;
ui.revokeId = null;
ui.receiveSeenIndex = null;
ui.txDetail = null;
@@ -1005,12 +1009,48 @@ function createGiftLink() {
const min = giftMinimum(rate);
const amt = parseAmount(ui.giftAmount, unit); // entered in the current display unit
if (!amt || amt < min) { ui.giftError = t('giftAmountInvalid', { n: fmtAmount(min) + ' ' + 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 = wallet.giftLockPreview(amt);
+ const splitFee = Math.ceil((11 + 68 + 31 * 2) * Math.max(1, Math.round(rate)));
+ const freed = lock != null ? lock - amt - 294 : 0;
+ if (lock != null && freed > splitFee && !wallet.offline) {
+ ui.giftSplitOffer = { amt, lock, freed, fee: splitFee };
+ ui.giftError = '';
+ render();
+ return;
+ }
+ doCreateGift(amt, rate);
+}
+function doCreateGift(amt, rate) {
try {
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).
+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.giftSplitOffer = null;
+ wallet.scan().catch(() => {});
+ } catch (e) {
+ ui.giftError = e.message || t('giftSplitFailed');
}
+ ui.busy = false;
render();
}
function giftCard() {
@@ -1027,9 +1067,26 @@ 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 = ''; render(); } }, t('giftAnother'))
+ h('button', { class: 'btn-sm grow', onClick: () => { ui.giftCode = null; ui.giftAmount = ''; ui.giftSplitTxid = null; render(); } }, t('giftAnother'))
)
)
+ : ui.giftSplitOffer
+ ? (() => {
+ const o = ui.giftSplitOffer;
+ const u = ' ' + unitLabel();
+ 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 })),
+ 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: 'linklike small', style: 'align-self:center', onClick: () => { ui.giftSplitOffer = null; render(); } }, t('back'))
+ )
+ );
+ })()
: h('div', { class: 'col gap6' },
h('div', { class: 'input-group' },
h('input', { type: 'number', step: unit === 'sats' ? '1' : '0.00000001', min: '0', inputmode: 'decimal', placeholder: t('giftAmountLabel'),
@@ -1037,6 +1094,7 @@ 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'))
),
@@ -1567,7 +1625,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.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.giftSplitTxid = null; ui.revokeId = null; render(); } }, t('back'))
);
}
diff --git a/src/i18n.js b/src/i18n.js
index 8e7cbf4..a4f2180 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -195,6 +195,11 @@ const STR = {
giftAmountInvalid: 'Enter at least {n}.',
giftMinNote: 'Minimum {n} at current fees, so it clears dust when claimed.',
giftAnother: 'Create another',
+ 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})',
giftReclaim: 'Reclaim',
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.