aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-24 22:59:07 -0700
committerAdam Soltys <[email protected]>2026-06-24 22:59:07 -0700
commit4d1a9aea0f6b234dfe8eb36d398002ccea6a5680 (patch)
tree10eed0842669e73fef36888182fc556fb5e2ed15 /src
parentfabfff494c5c5a76ccfb0021f3a7c0fe6f6d9577 (diff)
downloadcoinosv3-4d1a9aea0f6b234dfe8eb36d398002ccea6a5680.tar.gz
coinosv3-4d1a9aea0f6b234dfe8eb36d398002ccea6a5680.zip
Allow gifting the whole balance (no-change sweep) via Max
A gift PSBT commits a sender change output (>= dust), so the entered amount always needed gift + 294 — making it impossible to gift the entire balance. Add a Max toggle on the gift form that builds a no-change sweep: all inputs SIGHASH_NONE, no change output, so the recipient gets the whole balance minus their claim fee. previewGift/buildClaimTx now treat a zero-output PSBT as zero change. A specific-amount gift that can't keep a dust change now points the user to Max instead of failing with a confusing 'not enough funds'. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/app.js43
-rw-r--r--src/i18n.js3
-rw-r--r--src/wallet.js51
3 files changed, 75 insertions, 22 deletions
diff --git a/src/app.js b/src/app.js
index eef6f29..95a5625 100644
--- a/src/app.js
+++ b/src/app.js
@@ -49,6 +49,7 @@ const ui = {
giftAmount: '', // gift-create amount input
giftCode: null, // last-created gift PSBT code
giftError: '',
+ giftMax: false, // gift the whole spendable balance (no-change sweep)
giftSplitOffer: null, // { amt, lock, freed, fee } when offering to split a coin first
revokeId: null, // outpoint of a gift being revoked (confirm state)
claimCode: null, // gift code being claimed (opened from a #gift= link)
@@ -876,6 +877,7 @@ function lock() {
ui.giftAmount = '';
ui.giftCode = null;
ui.giftError = '';
+ ui.giftMax = false;
ui.giftSplitOffer = null;
ui.revokeId = null;
ui.receiveSeenIndex = null;
@@ -1007,17 +1009,28 @@ function giftRate() {
}
function createGiftLink() {
const rate = giftRate();
+ if (ui.giftMax) { doCreateGiftAll(rate); return; }
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; }
+ const spendable = wallet.spendable;
+ if (amt > spendable) { ui.giftError = t('giftExceedsBalance'); render(); return; }
+ // 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) {
+ 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 = 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) {
+ const freed = lock - amt - 294;
+ if (freed > splitFee && !wallet.offline) {
ui.giftSplitOffer = { amt, lock, freed, fee: splitFee };
ui.giftError = '';
render();
@@ -1036,6 +1049,19 @@ function doCreateGift(amt, rate) {
}
render();
}
+// Gift the whole spendable balance as a no-change sweep (the recipient receives
+// everything minus their claim fee).
+function doCreateGiftAll(rate) {
+ try {
+ ui.giftCode = wallet.createGiftAll(rate).code;
+ ui.giftError = '';
+ ui.giftMax = false;
+ ui.giftSplitOffer = null;
+ } catch (e) {
+ ui.giftError = e.message;
+ }
+ render();
+}
// Split + gift in one tap: broadcast a self-send carving out a right-sized
// coin, then immediately build the gift from that (unconfirmed) carve-out, so
// only ~the gift amount is locked and there's no confirmation wait. The link is
@@ -1069,7 +1095,7 @@ 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.giftMax = false; render(); } }, t('giftAnother'))
)
)
: ui.giftSplitOffer
@@ -1092,10 +1118,13 @@ function giftCard() {
: 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'),
- value: ui.giftAmount, onInput: (e) => (ui.giftAmount = e.target.value) }),
+ disabled: ui.giftMax,
+ value: ui.giftMax ? (unit === 'sats' ? String(wallet.spendable) : fmtBtc(wallet.spendable)) : ui.giftAmount,
+ onInput: (e) => (ui.giftAmount = e.target.value) }),
+ h('button', { type: 'button', class: ui.giftMax ? 'btn-primary' : '', onClick: () => { ui.giftMax = !ui.giftMax; ui.giftError = ''; render(); } }, t('max')),
h('div', { style: 'display:flex;align-items:center' }, unitTag())
),
- h('div', { class: 'small faint' }, t('giftMinNote', { n: fmtAmount(giftMinimum(giftRate())) + ' ' + unitLabel() })),
+ h('div', { class: 'small faint' }, ui.giftMax ? t('giftAllNote') : t('giftMinNote', { n: fmtAmount(giftMinimum(giftRate())) + ' ' + unitLabel() })),
ui.giftError && h('div', { class: 'notice err' }, ui.giftError),
h('button', { class: 'btn-block', onClick: createGiftLink }, t('giftLinkReveal'))
),
@@ -1667,7 +1696,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.giftSplitOffer = null; ui.revokeId = null; render(); } }, t('back'))
+ h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.giftMode = false; ui.giftCode = null; ui.giftError = ''; ui.giftMax = false; ui.giftSplitOffer = null; ui.revokeId = null; render(); } }, t('back'))
);
}
diff --git a/src/i18n.js b/src/i18n.js
index 83c5ecb..28ee249 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -195,6 +195,9 @@ const STR = {
giftAmountLabel: 'Amount to gift',
giftAmountInvalid: 'Enter at least {n}.',
giftMinNote: 'Minimum {n} at current fees, so it clears dust when claimed.',
+ giftAllNote: 'Gifts your whole balance; the recipient pays the claim fee.',
+ giftExceedsBalance: 'That’s more than your balance.',
+ giftNeedsHeadroom: 'Gift up to {n}, or tap Max to gift your whole balance.',
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',
diff --git a/src/wallet.js b/src/wallet.js
index 5b524e6..af827f3 100644
--- a/src/wallet.js
+++ b/src/wallet.js
@@ -914,7 +914,7 @@ export class Wallet {
_selectGiftCoins(gift) {
const DUST = 294n;
const need = gift + DUST;
- const avail = this.utxos.filter((u) => u.confirmed && !this.isReserved(utxoId(u)));
+ const avail = this.utxos.filter((u) => !this.isReserved(utxoId(u)) && (u.confirmed || u.chain === 1));
const single = avail.filter((u) => BigInt(u.value) >= need).sort((a, b) => a.value - b.value)[0];
if (single) return [single];
const sel = [];
@@ -935,27 +935,43 @@ export class Wallet {
return sel ? sel.reduce((s, u) => s + u.value, 0) : null;
}
- // Build a SIGHASH_SINGLE-signed gift from already-chosen coins: output0 =
- // change back to us (cryptographically fixed), leaving the rest for the
- // claimer to direct to their own fresh address. Reserves the coins, returns
- // { code, amount, reserved }. Callers ensure sum(sel) >= gift + dust.
+ // 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
+ // locked while the claimer is free to add their own output. If there's no
+ // viable change (a whole-balance gift), there's no output at all and every
+ // input is SIGHASH_NONE, so the claimer sweeps the entire amount. Reserves the
+ // coins; returns { code, amount, reserved } where amount is what the claimer
+ // receives before their fee (the gift, or the whole swept sum).
_buildGiftPsbt(sel, gift) {
+ const DUST = 294n;
const sum = sel.reduce((s, u) => s + BigInt(u.value), 0n);
- const change = sum - gift; // back to us, >= DUST by construction
+ const change = sum - gift;
+ const keepChange = change >= DUST;
+ const sigOf = (i) => (keepChange && i === 0 ? btc.SigHash.SINGLE : btc.SigHash.NONE);
const t = new btc.Transaction({ allowUnknownOutputs: true });
- // First input commits the change output (SIGHASH_SINGLE on output 0); the
- // rest commit only the inputs (SIGHASH_NONE). Together: inputs and our
- // change are locked, while the claimer is free to add their own output.
sel.forEach((u, i) => {
const pay = p2wpkh(this.derive(u.chain, u.index).pubkey, this.netCfg.net);
- t.addInput({ ...pay, txid: u.txid, index: u.vout, sighashType: i === 0 ? btc.SigHash.SINGLE : btc.SigHash.NONE, witnessUtxo: { script: pay.script, amount: BigInt(u.value) } });
+ t.addInput({ ...pay, txid: u.txid, index: u.vout, sighashType: sigOf(i), witnessUtxo: { script: pay.script, amount: BigInt(u.value) } });
});
- t.addOutputAddress(this.freshChange().address, change, this.netCfg.net);
- sel.forEach((u, i) => t.signIdx(this.node(u.chain, u.index).privateKey, i, [i === 0 ? btc.SigHash.SINGLE : btc.SigHash.NONE]));
+ if (keepChange) t.addOutputAddress(this.freshChange().address, change, this.netCfg.net);
+ sel.forEach((u, i) => t.signIdx(this.node(u.chain, u.index).privateKey, i, [sigOf(i)]));
for (const u of sel) this.reservedSet().add(utxoId(u));
this._saveReserved();
- return { code: base64urlnopad.encode(t.toPSBT()), amount: Number(gift), reserved: sel.map(utxoId) };
+ return { code: base64urlnopad.encode(t.toPSBT()), amount: Number(keepChange ? gift : sum), reserved: sel.map(utxoId) };
+ }
+
+ // Gift the entire spendable balance: a no-change sweep of all unreserved
+ // spendable coins (confirmed, plus our own unconfirmed change). The claimer
+ // receives the whole amount minus their claim fee.
+ createGiftAll(feeRate) {
+ const rate = Math.max(1, Math.round(feeRate));
+ const sel = this.utxos.filter((u) => !this.isReserved(utxoId(u)) && (u.confirmed || u.chain === 1));
+ if (!sel.length) throw new Error('No coins available to gift.');
+ const sum = sel.reduce((s, u) => s + BigInt(u.value), 0n);
+ if (sum < BigInt(giftMinimum(rate))) throw new Error('Gift amount is too small.');
+ return this._buildGiftPsbt(sel, sum); // change = 0 → no-change sweep
}
// Build a gift link from a best-fit coin, locking the least value. No fee is
@@ -1536,13 +1552,18 @@ function _sumInputs(tx) {
for (let i = 0; i < tx.inputsLength; i++) inAmt += tx.getInput(i).witnessUtxo.amount;
return inAmt;
}
+// The sender's change committed in a gift PSBT, or 0 for a no-change (whole-
+// balance) gift that has no outputs at all.
+function _giftChange(tx) {
+ return tx.outputsLength > 0 ? tx.getOutput(0).amount : 0n;
+}
export function previewGift(code) {
try {
const tx = btc.Transaction.fromPSBT(base64urlnopad.decode(code));
// room is the full amount the claimer receives (inputs minus our change);
// the claim fee is subtracted from it at claim time, so report inputs too
// so the caller can size that fee for this PSBT.
- return { room: Number(_sumInputs(tx) - tx.getOutput(0).amount), inputs: tx.inputsLength };
+ return { room: Number(_sumInputs(tx) - _giftChange(tx)), inputs: tx.inputsLength };
} catch {
return null;
}
@@ -1550,7 +1571,7 @@ export function previewGift(code) {
export function buildClaimTx(code, toAddress, feeRate, net) {
const tx = btc.Transaction.fromPSBT(base64urlnopad.decode(code));
- const room = _sumInputs(tx) - tx.getOutput(0).amount;
+ const room = _sumInputs(tx) - _giftChange(tx);
const fee = BigInt(Math.max(1, Math.ceil((11 + 68 * tx.inputsLength + 31 * 2) * Math.max(1, Math.round(feeRate)))));
const out = room - fee;
if (out < 294n) throw new Error('Gift is too small to claim at this fee rate.');