aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/gifts.js36
-rw-r--r--src/i18n.js1
2 files changed, 37 insertions, 0 deletions
diff --git a/src/features/gifts.js b/src/features/gifts.js
index 2b8c6c7..8aec560 100644
--- a/src/features/gifts.js
+++ b/src/features/gifts.js
@@ -459,6 +459,12 @@ export function giftsFeature(ctx) {
render();
}
}).catch(() => {});
+ // A broadcast that returns OK is not proof WE won: the on-chain gift's
+ // presigned input is redirectable, so a higher-fee sweep can evict our
+ // claim and the coin lands elsewhere — the "success then empty wallet"
+ // the sweeper bot produced. Watch the funding coin: if it gets spent by
+ // a txid that ISN'T ours, retract the celebration and say it was taken.
+ verifyClaimWon(giftOutpoints(ui.claimCode)[0], claim.txid).catch(() => {});
} catch (e) {
// Broadcast failed — most likely someone claimed it in the race window.
// Re-check the funding coin and, if spent, show the "already claimed" screen.
@@ -479,6 +485,29 @@ export function giftsFeature(ctx) {
render();
}
+ // Poll the funding coin after an on-chain claim: if it settles spent by a
+ // DIFFERENT txid than ours, a competing sweep won the race — retract the
+ // false "claimed!" celebration and show the taken screen. Best-effort;
+ // stops as soon as it sees a confirmed spender.
+ async function verifyClaimWon(op, ourTxid) {
+ if (!op || !ourTxid) return;
+ for (let i = 0; i < 6; i++) {
+ await new Promise((r) => setTimeout(r, i < 2 ? 4000 : 12000));
+ let res;
+ try { res = await wallet.api.outspend(op.txid, op.vout); } catch { continue; }
+ if (!res || !res.spent) continue;
+ if (res.txid && res.txid !== ourTxid) {
+ // someone else's tx spent the gift coin — our claim lost
+ ui.giftJustClaimed = null; ui.arkLnPaid = null;
+ ui.claimStep = 'welcome'; ui.claimedAmount = 0;
+ ui.claimTaken = { txid: res.txid };
+ render();
+ return;
+ }
+ if (res.status && res.status.confirmed) return; // ours confirmed — done
+ }
+ }
+
// Re-view a previously created gift's link + QR (from its stored record), so
// the sender can re-share an unclaimed gift.
function viewGiftView() {
@@ -642,6 +671,13 @@ export function giftsFeature(ctx) {
ui.giftSource === 'ark' ? t('giftArkNote')
: ui.giftMax ? t('giftAllNote')
: t('giftMinNote', { n: fmtAmount(giftMinimum(giftRate())) + ' ' + unitLabel() })),
+ // Interim safety: on-chain gift links are bearer instruments whose
+ // presigned input is currently redirectable (a sweeper bot is
+ // actively front-running shared links). Warn hard until the
+ // ephemeral-key redesign lands; Spending (ark) gifts are unaffected.
+ ui.giftSource === 'chain'
+ ? h('div', { class: 'notice warn small' }, t('giftChainWarn'))
+ : null,
ui.giftError && h('div', { class: 'notice err' }, ui.giftError),
ui.busy
? h('button', { class: 'btn-block', disabled: true }, h('span', { class: 'spinner sm' }))
diff --git a/src/i18n.js b/src/i18n.js
index b5db206..86fed95 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -911,6 +911,7 @@ const STR = {
lnPayNetworkFee: 'Network fee',
lnPayTotal: 'Total',
lnPayConfirm: 'Pay',
+ giftChainWarn: '\u26a0 On-chain gift links are bearer: anyone who gets the link — or sees the claim on the network — can sweep it. Share it privately and have the recipient claim right away. (A safer redesign is coming; Spending gifts aren\u2019t affected.)',
lnAutopayOffer: 'Auto-pay {name} up to {n} next time',
lnPaySentTitle: 'Lightning payment sent',
lnPaySentBody: 'The swap provider pays the invoice once the on-chain lockup confirms.',