diff options
| author | Adam Soltys <[email protected]> | 2026-06-24 20:40:38 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-24 20:40:38 -0700 |
| commit | ae62886bb3a7fe72a3828089c58205662696bca9 (patch) | |
| tree | b31d8e07e6cae91db5ed2057b3d5f2c22002ec07 /src/app.js | |
| parent | a6bfb5bd4429cf7069a5d0c5e61cb5873ddd8230 (diff) | |
| download | coinosv3-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]>
Diffstat (limited to 'src/app.js')
| -rw-r--r-- | src/app.js | 62 |
1 files changed, 60 insertions, 2 deletions
@@ -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')) ); } |
