aboutsummaryrefslogtreecommitdiffstats
path: root/src/app.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-24 09:45:24 -0700
committerAdam Soltys <[email protected]>2026-06-24 09:45:24 -0700
commitffb5c30b54f913746cd6f9f79166cc4d0842dc78 (patch)
tree8b14f0e828cd38b0343a4cdb598a6bc111c0c991 /src/app.js
parentb72534bc4284992f7e28597e6630bdf6db932e1c (diff)
downloadcoinosv3-ffb5c30b54f913746cd6f9f79166cc4d0842dc78.tar.gz
coinosv3-ffb5c30b54f913746cd6f9f79166cc4d0842dc78.zip
Add shareable gift links for fast onboarding
Create a #claim=<code> link from Settings → "Gift this wallet" (gated behind a reveal + warning, with a QR): the seed (and passphrase, if any) are encoded as BIP39 entropy in base64url in the URL fragment, which never reaches a server. Opening such a link loads that wallet and shows a welcome → balance → firm "back up your recovery phrase" flow, then drops into the wallet. The claim code is scrubbed from the address bar on load. Bearer by design — whoever opens the link controls the funds — so the creator UI warns to keep amounts small. New strings translated into all 19 locales. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Diffstat (limited to 'src/app.js')
-rw-r--r--src/app.js99
1 files changed, 92 insertions, 7 deletions
diff --git a/src/app.js b/src/app.js
index 911bca0..cf5a9de 100644
--- a/src/app.js
+++ b/src/app.js
@@ -4,7 +4,7 @@
// which rebuilds the active screen. Text inputs write back into `ui` on `input`
// (without re-rendering) so their values survive structural re-renders.
-import { Wallet, newMnemonic, isValidMnemonic, utxoId } from './wallet.js';
+import { Wallet, newMnemonic, isValidMnemonic, utxoId, encodeGift, decodeGift } from './wallet.js';
import { qrSvg } from './qr.js';
import { scanQr } from './scan.js';
import { getSyncConfig, setSyncConfig } from './nostr.js';
@@ -23,7 +23,8 @@ import {
const wallet = new Wallet();
const ui = {
- screen: 'unlock', // 'unlock' | 'wallet' | 'howItWorks'
+ screen: 'unlock', // 'unlock' | 'wallet' | 'claim' | 'howItWorks'
+ claimStep: null, // 'welcome' | 'backup' when opening a gift link
returnScreen: 'unlock', // where 'howItWorks' returns to (Back / logo)
unlockTab: 'create', // 'create' | 'import'
createStep: 'gen', // 'gen' | 'confirm'
@@ -34,6 +35,7 @@ const ui = {
passphrase: '',
showPass: false,
revealShown: false, // recovery phrase unmasked on the Backup tab (after the warning)
+ giftShown: false, // gift link revealed in Settings
offlineFallback: false, // auto-entered offline because the network was unreachable
unlockError: '',
@@ -148,9 +150,11 @@ function render() {
const screen =
ui.screen === 'wallet'
? walletScreen()
- : ui.screen === 'howItWorks'
- ? howItWorksScreen()
- : unlockScreen();
+ : ui.screen === 'claim'
+ ? claimScreen()
+ : ui.screen === 'howItWorks'
+ ? howItWorksScreen()
+ : unlockScreen();
root.replaceChildren(screen, footer());
}
wallet.subscribe(render);
@@ -492,11 +496,13 @@ async function openWallet(mnemonic) {
// Load a wallet and start scanning. Persists to sessionStorage so a refresh
// restores the open wallet (cleared on logout or when the tab closes).
-async function enterWallet(mnemonic, passphrase) {
+async function enterWallet(mnemonic, passphrase, opts = {}) {
wallet.load({ mnemonic, passphrase, netName: 'mainnet', offline: false });
persistSession(mnemonic, passphrase);
const hadCache = wallet.restoreCache(); // show last-known balance/history instantly
- ui.screen = 'wallet';
+ // A claimed gift link starts on a welcome/back-up screen instead of the wallet.
+ ui.screen = opts.claim ? 'claim' : 'wallet';
+ ui.claimStep = 'welcome';
ui.tab = 'receive';
// Not baselined yet — stays null until the scan + ack logic below sets it,
// so the celebration never fires for payments that were already there at
@@ -598,6 +604,7 @@ function lock() {
ui.passphrase = '';
ui.confirm = [];
ui.revealShown = false;
+ ui.giftShown = false;
ui.receiveSeenIndex = null;
ui.txDetail = null;
ui.broadcastTx = null;
@@ -675,6 +682,7 @@ function settingsTab() {
wallet.scanning ? t('scanning') : t('rescanWallet'))
),
explorerCard(),
+ giftCard(),
syncCard(),
h(
'div',
@@ -685,6 +693,32 @@ function settingsTab() {
);
}
+// Gift link: turn this whole wallet into a #claim= link (seed in the URL
+// fragment) for quick onboarding. Gated behind a reveal + warning, since the
+// link is bearer — whoever opens it controls the funds.
+function giftUrl() {
+ return `${location.origin}/#claim=${encodeGift(wallet.mnemonic, wallet.passphrase)}`;
+}
+function giftCard() {
+ return h(
+ 'div',
+ { class: 'card col' },
+ h('h3', {}, t('giftLink')),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('giftLinkDesc')),
+ h('div', { class: 'warn-box' }, t('giftLinkWarn')),
+ ui.giftShown
+ ? h('div', { class: 'col', style: 'align-items:center;gap:10px' },
+ h('div', { html: qrSvg(giftUrl()) }),
+ 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.giftShown = false; render(); } }, t('hide'))
+ )
+ )
+ : h('button', { class: 'btn-block', onClick: () => { ui.giftShown = true; render(); } }, t('giftLinkReveal'))
+ );
+}
+
// Block explorer / server selection: a preset (mempool.space, blockstream.info)
// or a custom Esplora/electrs REST URL (e.g. your own node).
function explorerCard() {
@@ -815,6 +849,55 @@ function goHome() {
render();
}
+// ================================================================ GIFT / CLAIM
+// Read a #claim=<code> gift link, returning the seed (and scrubbing it from the
+// URL so it doesn't linger in the address bar or history).
+function readClaimHash() {
+ try {
+ const m = location.hash.match(/^#claim=([A-Za-z0-9_~-]+)$/);
+ if (!m) return null;
+ history.replaceState(null, '', location.pathname + location.search);
+ return decodeGift(m[1]);
+ } catch {
+ return null;
+ }
+}
+
+// Onboarding flow shown when a gift link is opened: celebrate + balance, then a
+// firm nudge to back up the recovery phrase (the recipient now owns this wallet).
+function claimScreen() {
+ if (ui.claimStep === 'backup') {
+ const words = wallet.mnemonic.split(' ');
+ return h(
+ 'div',
+ { class: 'col', style: 'gap:16px' },
+ brandHeader(false),
+ h('div', { class: 'card col' },
+ h('h3', {}, t('recoveryPhrase')),
+ h('div', { class: 'warn-box' }, t('writeDownWarn')),
+ h('div', { class: 'words' },
+ words.map((w, i) => h('div', { class: 'w' }, h('span', { class: 'n' }, i + 1), h('span', { class: 't' }, w)))
+ ),
+ h('div', { class: 'row gap6' }, copyBtn(wallet.mnemonic, t('copyPhrase'))),
+ h('button', { class: 'btn-primary btn-block', onClick: () => { ui.screen = 'wallet'; ui.claimStep = null; render(); } }, t('giftBackedUp'))
+ )
+ );
+ }
+ 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')),
+ h('p', { class: 'muted', style: 'margin:0' }, t('giftWelcomeBody'))
+ ),
+ balanceCard(),
+ h('button', { class: 'btn-primary btn-block', onClick: () => { ui.claimStep = 'backup'; render(); } }, t('giftBackup')),
+ h('button', { class: 'btn-block', onClick: () => { ui.screen = 'wallet'; ui.claimStep = null; render(); } }, t('giftSkip'))
+ );
+}
+
function walletScreen() {
return h(
'div',
@@ -1640,6 +1723,8 @@ async function importSnapshotFile(e) {
// show the unlock screen.
applyDir();
loadLocale(getLang()).finally(() => {
+ const gift = readClaimHash();
+ if (gift) { enterWallet(gift.mnemonic, gift.passphrase, { claim: true }); return; }
if (!restoreSession()) render();
});