diff options
| author | Adam Soltys <[email protected]> | 2026-09-02 22:18:16 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-02 22:18:16 -0700 |
| commit | d28b716ae791f924d7dea901b9a9711e44bd7312 (patch) | |
| tree | ed3c2307ab1ee55a8e33fb7d873c5d521dd3ed87 | |
| parent | ea5bd5bfd7f6d41c64319847597894e61a61f126 (diff) | |
| download | coinosv3-d28b716ae791f924d7dea901b9a9711e44bd7312.tar.gz coinosv3-d28b716ae791f924d7dea901b9a9711e44bd7312.zip | |
The Amethyst handshake survives its own launch: opening the signer app freezes the tab (relay sockets die as the approval publishes) and its callbackUrl then RELOADS the page, discarding the client key mid-handshake — so the pending connect now persists to sessionStorage, the wait races the live subscription against a stored-event poller kicked on every return to visibility, and a fresh boot resumes the handshake instead of shrugging. Proven both ways with a scripted signer: approval after reload, and approval stored while the page was gone
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MrkLsNScXe9b6BviUSdmMr
| -rw-r--r-- | src/features/nostrlogin.js | 22 | ||||
| -rw-r--r-- | src/nostr-login.js | 114 |
2 files changed, 123 insertions, 13 deletions
diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js index b891572..ec230e5 100644 --- a/src/features/nostrlogin.js +++ b/src/features/nostrlogin.js @@ -14,7 +14,7 @@ import { t } from '../i18n.js'; import { qrSvg } from '../qr.js'; import { extensionSigner, bunkerSigner, keySigner, parseNostrSecret, - walletForSigner, publishWalletBackup, fetchWalletBackup, nostrConnect, resumeBunker } from '../nostr-login.js'; + walletForSigner, publishWalletBackup, fetchWalletBackup, nostrConnect, nostrConnectPending, resumeBunker } from '../nostr-login.js'; // The nostr ostrich, in the brand's monochrome. const OSTRICH = '<svg width="23" height="23" style="display:block" viewBox="0 0 2000 2000" fill="none" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="ng" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#B84FE8"/><stop offset="100%" stop-color="#6D28D9"/></linearGradient></defs><circle cx="1000" cy="1000" r="1000" fill="url(#ng)"/><g transform="translate(1000 1000) scale(0.82) translate(-1000 -1000)"><path d="M1574.29 770.408C1532.04 840.612 1445.92 888.367 1437.96 892.449C1421.84 901.02 1415.92 913.469 1416.94 932.041C1417.96 950.612 1413.47 1042.65 1357.35 1083.27C1328.57 1104.08 1212.45 1131.02 1159.18 1140C1131.84 1144.69 1125.51 1153.06 1110.41 1162.04C1090 1174.69 1017.55 1275.1 1005.31 1297.35C1006.94 1297.14 1233.67 1226.73 1243.88 1225.1C1270.82 1221.02 1291.22 1228.78 1304.49 1248.37C1317.14 1267.14 1329.59 1286.12 1342.04 1305.1C1346.53 1311.84 1350.82 1318.78 1355.1 1325.71L1359.59 1332.86C1361.22 1335.51 1363.47 1338.57 1364.49 1342.65C1365.31 1345.51 1367.14 1355.1 1361.63 1360.82C1356.53 1366.12 1347.76 1366.12 1343.06 1364.9C1338.57 1363.67 1331.84 1361.02 1326.33 1356.53L1318.78 1350.2C1312.86 1345.1 1301.63 1342.65 1292.86 1343.06C1282.65 1343.47 1269.18 1340.2 1262.65 1336.12C1250 1327.96 1246.73 1307.55 1246.73 1307.35C1245.1 1300.61 1240.2 1298.78 1235.71 1298.78C1227.55 1298.57 1221.22 1301.02 1214.69 1302.86C1184.9 1311.84 1155.31 1321.02 1125.51 1330.41L1082.86 1343.67C1061.84 1350.2 1040.82 1356.73 1020 1363.67C1014.29 1365.51 1008.78 1368.37 1002.86 1371.43C1000.41 1372.65 997.959 1373.88 995.51 1375.1C993.061 1376.33 990.408 1377.76 987.959 1379.18C982.449 1382.24 976.531 1385.51 970 1387.35C948.776 1393.67 930.204 1384.49 921.429 1363.27C915.714 1349.59 920.816 1322.45 923.878 1314.49C943.061 1264.69 1005.51 1170.82 1005.51 1170.2C1004.49 1169.8 936.327 1197.14 925.102 1205.71C894.286 1229.18 822.041 1284.69 821.02 1290C816.327 1314.69 801.02 1333.27 777.551 1342.45C760.612 1348.98 750.408 1362.45 740.408 1375.51C740.408 1375.51 559.388 1624.9 547.143 1638.37C543.469 1642.45 509.592 1677.96 500.612 1694.49C498.571 1698.57 490.816 1714.9 488.776 1718.98C487.143 1722.24 483.469 1731.02 469.592 1730.41C455.714 1729.8 455.306 1710.41 454.694 1702.04C453.265 1681.43 457.755 1661.22 468.571 1640C469.592 1637.96 481.837 1619.39 477.347 1604.49C473.265 1591.43 469.388 1571.22 475.51 1561.02C485.102 1544.9 502.041 1544.29 524.694 1540.61C540.408 1537.96 551.837 1530.2 562.857 1514.08C586.122 1480.2 673.674 1355.92 693.469 1327.14C698.776 1319.59 702.857 1309.8 704.694 1300.2C710.408 1271.22 727.959 1251.02 758.571 1238.57C765.102 1235.92 886.939 1134.08 886.939 1121.02C886.939 1111.63 873.674 1106.53 863.469 1103.67C862.041 1103.27 785.51 1083.47 750.612 1067.55C731.633 1058.98 717.347 1051.43 700.408 1039.18C677.347 1022.24 631.225 1030.41 624.49 1031.43C591.837 1036.12 567.551 1050.82 540 1069.39C535.102 1072.86 510 1081.84 497.347 1074.69C472.653 1060.61 418.367 1035.31 398.367 1004.9C389.184 990.816 390.408 967.959 396.939 942.653C417.959 883.878 448.98 857.551 506.327 841.633C547.143 827.551 629.388 824.49 668.98 821.429C671.633 820.612 759.388 818.571 821.02 786.939C862.041 765.918 983.878 684.898 1159.18 690.408C1230 692.653 1363.88 761.837 1415.51 763.878C1468.16 766.122 1494.49 760.204 1524.9 728.776C1533.47 719.796 1570 641.02 1535.51 597.959C1522.65 582.041 1509.39 567.755 1494.08 554.694C1473.47 537.143 1452.04 520.408 1433.47 500.612C1392.45 457.143 1379.8 397.755 1399.18 351.429C1411.63 318.98 1437.76 304.898 1475.51 312.245C1502.04 317.347 1521.43 341.225 1540.41 353.878C1551.02 361.02 1568.78 365.102 1580.41 367.959C1595.1 371.429 1606.12 382.653 1605.51 390.204C1604.9 397.755 1585.51 404.286 1566.94 402.857C1544.9 401.429 1507.14 401.225 1484.49 408.776C1469.39 414.286 1462.65 431.02 1467.14 447.551C1470.82 461.02 1499.59 485.102 1510.41 494.082C1532.04 511.837 1555.1 527.959 1573.06 550.204C1593.27 575.51 1603.88 604.286 1607.14 636.122C1612.04 684.898 1599.18 728.776 1574.29 770.408Z" fill="#fff"/></g></svg>'; @@ -209,12 +209,12 @@ export function nostrLoginFeature(ctx) { let nc = null; let ncRun = null; let ncToken = null; // guards the async gap while the nip46 module loads - async function startNostrConnect(run) { + async function startNostrConnect(run, resume = null) { if (nc) nc.cancel(); ncRun = run; const token = (ncToken = {}); let mine; - try { mine = await nostrConnect(); } catch { + try { mine = await nostrConnect({ resume }); } catch { if (token === ncToken && ui.nostrConnectOpen) { ui.nostrConnectOpen = false; render(); } return; } @@ -236,6 +236,22 @@ export function nostrLoginFeature(ctx) { ncToken = null; } + // A reload mid-nostrconnect (the signer app's callbackUrl navigating the + // tab home, or Android discarding the frozen tab) used to strand the + // login: the approval lands on the relays and nobody is waiting. On boot, + // pick the persisted handshake back up — the poller finds the stored + // approval and the login finishes on its own. Deferred a beat so the boot + // restore has decided whether this tab is even logged out. + setTimeout(() => { + const pending = !nc && !wallet.mnemonic && ui.screen === 'unlock' && nostrConnectPending(); + if (!pending) return; + if (ui.onb && ['welcome', 'signin'].includes(ui.onb.step)) ui.onb = { step: 'signin' }; + ui.nostrLoginOpen = true; + ui.nostrConnectOpen = true; + startNostrConnect(loginWith, pending); + render(); + }, 400); + function nostrConnectSection(run) { if (!ui.nostrConnectOpen) { return h('button', { diff --git a/src/nostr-login.js b/src/nostr-login.js index 86c6b79..56eb195 100644 --- a/src/nostr-login.js +++ b/src/nostr-login.js @@ -172,11 +172,33 @@ function bunkerAdapter(signer, pubkey, local) { // relay — no bunker URL to copy. Returns the URI (render it as a deep link // and a QR), a promise that resolves to a signer adapter when the app // answers, and a cancel. -export async function nostrConnect({ relays = ['wss://relay.coinos.io', 'wss://nos.lol'], timeoutMs = 180_000 } = {}) { +// +// SURVIVAL: launching the signer app on a phone FREEZES this tab — its relay +// sockets die exactly when the approval is being published, and a limit:0 +// subscription never replays what it slept through. Worse, a signer's +// callbackUrl can NAVIGATE the tab home, reloading the page and discarding +// the client key mid-handshake. So the pending handshake is persisted +// (sessionStorage) and the wait runs two paths: the library's live +// subscription, raced against a stored-event poller that re-queries the +// relays (kicked on every return to visibility) — whichever sees the +// approval first wins. `nostrConnectPending()` hands a fresh boot the +// persisted handshake so a reloaded page resumes instead of shrugging. +const NC_PENDING_KEY = 'nc-pending'; +export function nostrConnectPending(timeoutMs = 180_000) { + try { + const p = JSON.parse(sessionStorage.getItem(NC_PENDING_KEY) || 'null'); + if (p && p.local && p.secret && p.uri && Date.now() - p.ts < timeoutMs) return p; + if (p) sessionStorage.removeItem(NC_PENDING_KEY); + } catch {} + return null; +} +export async function nostrConnect({ relays = ['wss://relay.coinos.io', 'wss://nos.lol'], timeoutMs = 180_000, resume = null } = {}) { const { BunkerSigner, createNostrConnectURI } = await loadNip46(); - const local = generateSecretKey(); - const secret = hex.encode(crypto.getRandomValues(new Uint8Array(16))); - const uri = createNostrConnectURI({ + const local = resume ? hex.decode(resume.local) : generateSecretKey(); + const secret = resume ? resume.secret : hex.encode(crypto.getRandomValues(new Uint8Array(16))); + const startedAt = resume ? resume.ts : Date.now(); + if (resume && resume.relays) relays = resume.relays; + const uri = resume ? resume.uri : createNostrConnectURI({ clientPubkey: getPublicKey(local), relays, secret, @@ -184,15 +206,87 @@ export async function nostrConnect({ relays = ['wss://relay.coinos.io', 'wss://n url: 'https://v3.coinos.io', perms: ['get_public_key', 'sign_event', 'nip44_encrypt', 'nip44_decrypt'], }) + '&callbackUrl=' + encodeURIComponent('https://v3.coinos.io/'); + try { + sessionStorage.setItem(NC_PENDING_KEY, JSON.stringify({ + local: hex.encode(local), secret, relays, uri, ts: startedAt, + })); + } catch {} + const clientPub = getPublicKey(local); const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(new Error('signer connect timed out')), timeoutMs); + const timer = setTimeout(() => controller.abort(new Error('signer connect timed out')), + Math.max(5_000, timeoutMs - (Date.now() - startedAt))); + + // Path 1: the library's live wait — instant while the tab stays awake. + const live = (async () => BunkerSigner.fromURI(local, uri, {}, controller.signal))(); + live.catch(() => {}); + + // Path 2: the replay poller — finds an approval published while we slept. + let visKick = null; + const onVis = () => { if (document.visibilityState === 'visible' && visKick) visKick(); }; + document.addEventListener('visibilitychange', onVis); + const replay = (async () => { + const seen = new Set(); + while (!controller.signal.aborted) { + await new Promise((r) => { + visKick = r; + const t2 = setTimeout(r, 5000); + controller.signal.addEventListener('abort', () => { clearTimeout(t2); r(); }, { once: true }); + }); + visKick = null; + if (controller.signal.aborted) break; + const pool = new SimplePool(); + try { + const evs = await Promise.any(relays.map(async (url) => { + await pool.ensureRelay(url, { connectionTimeout: 4000 }); + const got = await pool.querySync([url], { + kinds: [24133], '#p': [clientPub], since: Math.floor(startedAt / 1000) - 120, + }, { maxWait: 4500 }); + if (!got || !got.length) throw new Error('nothing yet'); + return got; + })).catch(() => []); + for (const ev of evs || []) { + if (seen.has(ev.id)) continue; + seen.add(ev.id); + try { + const key = nip44.getConversationKey(local, ev.pubkey); + const resp = JSON.parse(nip44.decrypt(ev.content, key)); + // some signers echo the secret, others answer a bare "ack" + if (resp.result === secret || resp.result === 'ack') { + return BunkerSigner.fromBunker(local, { pubkey: ev.pubkey, relays, secret }); + } + } catch {} + } + } catch {} finally { try { pool.close(relays); } catch {} } + } + throw controller.signal.reason || new Error('cancelled'); + })(); + replay.catch(() => {}); + const ready = (async () => { - const signer = await BunkerSigner.fromURI(local, uri, {}, controller.signal); - const pubkey = await signer.getPublicKey(); - return bunkerAdapter(signer, pubkey, local); + try { + const signer = await Promise.any([live, replay]); + const pubkey = await signer.getPublicKey(); + try { sessionStorage.removeItem(NC_PENDING_KEY); } catch {} + return bunkerAdapter(signer, pubkey, local); + } catch (e) { + // an aborted wait names its real cause (timeout, user cancel); a raw + // AggregateError from the race names nothing useful + throw (controller.signal.aborted && controller.signal.reason) + || (e && e.errors && e.errors[0]) || e; + } finally { + clearTimeout(timer); + document.removeEventListener('visibilitychange', onVis); + controller.abort(new Error('settled')); + } })(); - ready.finally(() => clearTimeout(timer)).catch(() => {}); - return { uri, ready, cancel: (reason) => controller.abort(reason || new Error('cancelled')) }; + ready.catch(() => {}); + return { + uri, ready, + cancel: (reason) => { + try { sessionStorage.removeItem(NC_PENDING_KEY); } catch {} + controller.abort(reason || new Error('cancelled')); + }, + }; } // A pasted key: signing happens locally, and the key is never persisted. |
