aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-24 20:28:12 -0700
committerAdam Soltys <[email protected]>2026-06-24 20:28:12 -0700
commit08d1753e8433fcae6e9068e87cff93813f0ed76d (patch)
tree82e36ab3181c80d48217bb79a03940813e84b47d /src
parentaeb6a68e15b04ff35c8154a8f219dcca0a07de0e (diff)
downloadcoinosv3-08d1753e8433fcae6e9068e87cff93813f0ed76d.tar.gz
coinosv3-08d1753e8433fcae6e9068e87cff93813f0ed76d.zip
Gift links: stop pre-reserving fee, show it at claim time
Previously createGift reserved a claim-fee headroom on top of the entered amount based on the fee rate at creation. Because the fee was only actually charged at claim time, fee-rate drift between the two moments silently ate into (or padded) the recipient's amount. Now the entered amount is exactly the recipient's full received total (sum of inputs less our change); no fee is reserved. The claim screen shows that full total as the headline and surfaces the live network-fee estimate on the Claim button, where it's deducted from the recipient's balance at claim time. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/app.js13
-rw-r--r--src/i18n.js1
-rw-r--r--src/wallet.js20
3 files changed, 21 insertions, 13 deletions
diff --git a/src/app.js b/src/app.js
index c8ff8b1..4aca644 100644
--- a/src/app.js
+++ b/src/app.js
@@ -1288,8 +1288,12 @@ function claimScreen() {
);
}
const pv = previewGift(ui.claimCode);
- const estFee = Math.ceil((11 + 68 + 31 * 2) * ((wallet.feeRates && wallet.feeRates.halfHourFee) || 5));
- const approx = pv ? Math.max(0, pv.room - estFee) : 0;
+ // The headline is the full amount received (inputs minus the sender's change).
+ // The network fee is determined now, at claim time, and comes out of that
+ // amount โ€” we surface the estimate on the Claim button so it isn't a surprise.
+ const rate = Math.max(1, Math.round((wallet.feeRates && wallet.feeRates.halfHourFee) || 5));
+ const estFee = pv ? Math.ceil((11 + 68 * pv.inputs + 31 * 2) * rate) : 0;
+ const total = pv ? pv.room : 0;
return h(
'div',
{ class: 'col', style: 'gap:16px' },
@@ -1298,14 +1302,15 @@ function claimScreen() {
h('div', { class: 'check-badge', style: 'background:var(--accent)' }, '๐ŸŽ'),
h('h2', { style: 'margin:0' }, t('giftWelcome')),
h('div', { class: 'amt', style: 'font-size:30px' },
- h('span', { class: 'amount-pos' }, '~' + fmtAmount(approx)), ' ', unitTag('unit')
+ h('span', { class: 'amount-pos' }, fmtAmount(total)), ' ', unitTag('unit')
),
h('p', { class: 'muted', style: 'margin:0' }, t('claimBody'))
),
ui.claimError && h('div', { class: 'notice err' }, ui.claimError),
ui.busy
? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' }))
- : h('button', { class: 'btn-primary btn-block', onClick: doClaim }, t('claimBtn'))
+ : h('button', { class: 'btn-primary btn-block', onClick: doClaim },
+ t('claimBtn') + ' ยท ' + t('claimFeeNote', { n: fmtAmount(estFee) + ' ' + unitLabel() }))
);
}
diff --git a/src/i18n.js b/src/i18n.js
index e427733..42fd34b 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -206,6 +206,7 @@ const STR = {
copyLink: 'Copy link',
claimBody: 'Claim it into a brand-new wallet that only you control.',
claimBtn: 'Claim it',
+ claimFeeNote: '~{n} fee',
claimFailed: 'This gift has already been claimed or is no longer available.',
claimedTitle: 'Claimed!',
claimedBody: 'Your Bitcoin is on its way. Back up your recovery phrase to keep it safe.',
diff --git a/src/wallet.js b/src/wallet.js
index 194b9dc..72d33be 100644
--- a/src/wallet.js
+++ b/src/wallet.js
@@ -878,10 +878,10 @@ export class Wallet {
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 + the claimer's fee (which grows with input count) + a dust
- // change. The change output goes back to us and is protected; the rest is
- // the claimer's to direct.
- const claimFeeFor = (n) => BigInt(Math.ceil((11 + 68 * n + 31 * 2) * rate));
+ // 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);
@@ -890,12 +890,11 @@ export class Wallet {
for (const u of pool) {
sel.push(u);
sum += BigInt(u.value);
- if (sum >= gift + claimFeeFor(sel.length) + DUST) break;
+ if (sum >= gift + DUST) break;
}
- const claimFee = claimFeeFor(sel.length);
- if (sum < gift + claimFee + DUST) throw new Error('Not enough confirmed funds for that gift amount.');
+ if (sum < gift + DUST) throw new Error('Not enough confirmed funds for that gift amount.');
- const change = sum - gift - claimFee; // back to us, >= DUST by construction
+ const change = sum - gift; // back to us, >= DUST by construction
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
@@ -1423,7 +1422,10 @@ function _sumInputs(tx) {
export function previewGift(code) {
try {
const tx = btc.Transaction.fromPSBT(base64urlnopad.decode(code));
- return { room: Number(_sumInputs(tx) - tx.getOutput(0).amount) }; // room minus the claimer fee
+ // 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 };
} catch {
return null;
}