aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-28 17:32:55 -0700
committerAdam Soltys <[email protected]>2026-06-28 17:32:55 -0700
commit6fd4469cd09226a3a8672251bc825a26945fcad5 (patch)
tree155b561cbba8405b86fea8443de3afbbbbf31b99 /src
parenta267f7f37fd178ebb58871188067b571b18a67f1 (diff)
downloadcoinosv3-6fd4469cd09226a3a8672251bc825a26945fcad5.tar.gz
coinosv3-6fd4469cd09226a3a8672251bc825a26945fcad5.zip
Gifts: let recipients claim into an existing wallet, not just a new one
Opening a gift link when you already have wallet(s) now shows a 'Choose where to receive it' screen — each known wallet (full accounts this session plus the durable directory, deduped by xpub) plus 'Receive into a new wallet'. A first-timer with no wallets goes straight to a fresh wallet as before. Claiming into an existing wallet activates it non-provisionally and sweeps the gift to its receive address (watch-only is fine — re-enter the seed to spend), skipping the new-seed backup step. Both bearer and locked (NIP-17) gifts route through the same claimGift() entry. Verified in-browser: selection lists the wallet + new option + amount; claim-into-address mechanic proven on regtest. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/app.js86
-rw-r--r--src/i18n.js4
2 files changed, 82 insertions, 8 deletions
diff --git a/src/app.js b/src/app.js
index eaf9b86..a11691c 100644
--- a/src/app.js
+++ b/src/app.js
@@ -58,6 +58,7 @@ const ui = {
claimCodeInput: '', // the claim code the recipient pastes from their nostr DM
consolidateError: null, // error from a coin-consolidation attempt
viewGift: null, // re-viewing a previously created gift's link/QR { code, locked, amount, claimCode }
+ claimChoose: null, // opening a gift with existing wallets present: { code } — pick a target
giftCode: null, // last-created gift PSBT code
giftError: '',
giftMax: false, // gift the whole spendable balance (no-change sweep)
@@ -264,6 +265,8 @@ function render() {
? lockedGiftClaimView()
: ui.viewGift
? viewGiftView()
+ : ui.claimChoose
+ ? claimChooseView()
: ui.screen === 'wallet'
? walletScreen()
: ui.screen === 'accounts'
@@ -756,7 +759,7 @@ async function activateAccount(acc, opts = {}) {
// A gift link generates this wallet only to claim into. Keep it provisional
// until the user commits (claims, or chooses to keep it / enters the wallet),
// so bailing from an already-claimed gift doesn't leave an empty account.
- if (opts.gift) acc.provisional = true;
+ if (opts.gift && !opts.existingClaim) acc.provisional = true;
const netName = getNetwork();
if (acc.type === 'watch') wallet.load({ xpub: acc.xpub, netName, offline: false });
else if (acc.xprv) wallet.load({ xprv: acc.xprv, netName, offline: false });
@@ -1951,7 +1954,7 @@ async function claimViaExtension() {
const payload = await ext.nip44.decrypt(ui.claimLocked.eph, ui.claimLocked.ctKey);
if (!payload || !previewGift(payload)) { ui.claimError = t('extDecryptFailed'); ui.busy = false; render(); return; }
ui.busy = false; ui.claimLocked = null; ui.claimCodeInput = ''; ui.claimError = '';
- enterWallet(newMnemonic(), '', { gift: payload }); // fresh-wallet claim
+ claimGift(payload); // claim into an existing wallet or a fresh one
} catch {
ui.busy = false; ui.claimError = t('extDecryptFailed'); render();
}
@@ -1963,7 +1966,7 @@ function submitLockedCode() {
try { payload = decryptWithCode(code, ui.claimLocked.ct); } catch {}
if (!payload || !previewGift(payload)) { ui.claimError = t('claimCodeWrong'); render(); return; }
ui.claimLocked = null; ui.claimCodeInput = ''; ui.claimError = '';
- enterWallet(newMnemonic(), '', { gift: payload }); // fresh-wallet claim, like a normal gift
+ claimGift(payload); // claim into an existing wallet or a fresh one
}
// Has this gift already been claimed? Its funding coin being spent — even by an
@@ -1996,8 +1999,15 @@ async function doClaim() {
const claim = buildClaimTx(ui.claimCode, to, rate, wallet.netCfg.net);
await wallet.broadcast(claim.hex);
ui.claimedAmount = claim.amount;
- ui.claimStep = 'backup';
- commitAccount(); // claimed — this wallet now holds funds, keep it
+ const wasProvisional = !!(activeAccount() && activeAccount().provisional);
+ commitAccount(); // claimed — keep a fresh gift wallet (no-op for an existing one)
+ if (wasProvisional) {
+ ui.claimStep = 'backup'; // fresh wallet — show the new seed to back up
+ } else {
+ ui.screen = 'wallet'; // existing wallet — straight into it, no new seed to back up
+ ui.claimStep = null;
+ toast(t('giftClaimedToast'));
+ }
wallet.scan().catch(() => {});
} catch (e) {
// Broadcast failed — most likely someone claimed it in the race window.
@@ -2015,6 +2025,61 @@ async function doClaim() {
render();
}
+// Wallets a gift could be claimed into: full accounts open this session plus the
+// durable directory (every wallet's xpub, watch-only), deduped by xpub. Claiming
+// sends the gift to the wallet's address; a watch-only one then needs the seed
+// re-entered to spend. Empty for a first-time recipient.
+function claimTargets() {
+ let sess = null;
+ try { sess = JSON.parse(sessionStorage.getItem(ACCOUNTS_KEY) || 'null'); } catch {}
+ const open = ((sess && Array.isArray(sess.accounts)) ? sess.accounts : []).filter((a) => !a.provisional);
+ const seen = new Set(open.map((a) => a.xpub).filter(Boolean));
+ return [...open, ...loadWatchAccounts().filter((w) => !seen.has(w.xpub))];
+}
+
+// Entry point for claiming a gift code. If the recipient already has wallet(s),
+// offer to claim into one or make a new one; otherwise go straight to a fresh
+// wallet (the original first-timer flow).
+function claimGift(code) {
+ ui.claimLocked = null;
+ const targets = claimTargets();
+ if (targets.length) { accounts = targets; ui.claimChoose = { code }; render(); }
+ else enterWallet(newMnemonic(), '', { gift: code });
+}
+
+// Claim a gift into an existing wallet (no new seed). activateAccount loads it and
+// opens the claim screen; existingClaim keeps it committed and, in doClaim, skips
+// the seed-backup step.
+function claimIntoAccount(acc, code) {
+ ui.claimChoose = null;
+ activateAccount(acc, { gift: code, existingClaim: true, fresh: true });
+}
+
+// Pick where to receive a gift: an existing wallet, or a brand-new one.
+function claimChooseView() {
+ const code = ui.claimChoose.code;
+ const pv = previewGift(code);
+ return h(
+ 'div',
+ { class: 'col', style: 'gap:16px' },
+ brandHeader(false),
+ h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:12px' },
+ h('div', { class: 'check-badge', style: 'background:var(--accent)' }, '🎁'),
+ h('h2', { style: 'margin:0' }, t('giftWelcome')),
+ pv ? h('div', { class: 'amt', style: 'font-size:30px' }, h('span', { class: 'amount-pos' }, fmtAmount(pv.room)), ' ', unitTag('unit')) : null,
+ h('p', { class: 'muted', style: 'margin:0' }, t('claimIntoPrompt'))
+ ),
+ h('div', { class: 'card col gap6' },
+ ...accounts.map((a) =>
+ h('button', { class: 'btn-block', style: 'text-align:left', onClick: () => claimIntoAccount(a, code) },
+ a.label,
+ a.type === 'watch' ? h('span', { class: 'small faint', style: 'margin-left:6px' }, '(' + t('watchOnly') + ')') : null)
+ ),
+ h('button', { class: 'btn-primary btn-block', onClick: () => { ui.claimChoose = null; enterWallet(newMnemonic(), '', { gift: code }); } }, t('claimNewWallet'))
+ )
+ );
+}
+
// Gift-claim flow: a fresh wallet only the claimer controls. Step 'welcome'
// shows the amount + a Claim button; 'backup' (after a successful claim) shows
// the fresh recovery phrase to write down.
@@ -2108,7 +2173,12 @@ function claimScreen() {
),
// A fresh wallet was already generated for the claim — offer to keep it,
// view the claim on a block explorer, or head back to the home screen.
- h('button', { class: 'btn-primary btn-block', onClick: () => { ui.claimTaken = null; ui.claimedAmount = 0; ui.claimStep = 'backup'; commitAccount(); render(); } }, t('createWalletAnyway')),
+ h('button', { class: 'btn-primary btn-block', onClick: () => {
+ ui.claimTaken = null; ui.claimedAmount = 0;
+ if (activeAccount() && activeAccount().provisional) { ui.claimStep = 'backup'; commitAccount(); }
+ else { ui.screen = 'wallet'; ui.claimStep = null; }
+ render();
+ } }, activeAccount() && activeAccount().provisional ? t('createWalletAnyway') : t('goToWallet')),
ui.claimTaken.txid
? h('a', { class: 'btn btn-block', href: wallet.api.explorerTx(ui.claimTaken.txid), target: '_blank', rel: 'noopener', onClick: (e) => { e.preventDefault(); openExternal(wallet.api.explorerTx(ui.claimTaken.txid)); } }, t('viewOnMempool'))
: null,
@@ -3456,10 +3526,10 @@ document.addEventListener('visibilitychange', () => {
else onAppVisible();
});
loadLocale(getLang()).finally(() => {
+ applyBootAutoLogout(); // clear an overdue session before we read it for claim targets
const code = readGiftHash();
- if (code) { enterWallet(newMnemonic(), '', { gift: code }); return; }
+ if (code) { claimGift(code); return; } // bearer gift → claim into an existing wallet or a new one
ui.claimLocked = readLockedGiftHash(); // locked gift → claim with your own wallet
- applyBootAutoLogout(); // clear an overdue session before it's restored
if (!restoreAccountsState()) render();
});
diff --git a/src/i18n.js b/src/i18n.js
index 047509b..babe3e0 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -288,6 +288,10 @@ const STR = {
giftRevoked: 'Gift revoked',
copyLink: 'Copy link',
claimBody: 'Claim it into a brand-new wallet that only you control.',
+ claimIntoPrompt: 'Choose where to receive it.',
+ claimNewWallet: 'Receive into a new wallet',
+ goToWallet: 'Go to wallet',
+ giftClaimedToast: 'Gift claimed',
claimBtn: 'Claim it',
claimFeeNote: '~{n} fee',
claimFailed: 'This gift has already been claimed or is no longer available.',