From c1cb4bcd0aa1f2ac6fbae5dcf32570d02e5b8122 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Fri, 25 Sep 2026 11:29:43 +0000 Subject: [PATCH 1/2] feat: sidechain spends are opt-in, and each chain's validated state is cached; 0.0.10 - off until turned on, in settings or in the spend window when a site first asks (declining answers 'unsupported'); settings list the chains in use with their pinned signer and a Forget button; turning spends off forgets every chain; 0.0.9 pins carry over as turned on (src/sidestr/settings.js) - window.nostr.sidestr.enabled, read live from which the content script mirrors from the setting - the explorer resumes from its saved state and the holders' asset view from its own, both keyed to the block hash at the saved height; a mismatch drops both and every block is checked again. sidestr:dreamlab: 5.9 s -> 0.3 s, identical view, 47 KB saved; the window says what it checked Co-Authored-By: jjohare --- CHANGELOG.md | 22 ++++++++ README.md | 10 +++- manifest.json | 2 +- package.json | 2 +- popup/popup.css | 5 ++ popup/popup.html | 11 ++++ popup/popup.js | 43 ++++++++++++++++ popup/spend.css | 13 +++++ popup/spend.html | 15 ++++++ popup/spend.js | 72 ++++++++++++++++++++------ src/injected.js | 18 +++++++ src/nostr-provider.js | 11 ++++ src/sidestr/core.js | 49 +++++++++++++++--- src/sidestr/settings.js | 96 +++++++++++++++++++++++++++++++++++ test/provider-surface.test.js | 16 +++++- test/sidestr-core.test.js | 47 +++++++++++++++++ test/sidestr-settings.test.js | 92 +++++++++++++++++++++++++++++++++ 17 files changed, 498 insertions(+), 26 deletions(-) create mode 100644 src/sidestr/settings.js create mode 100644 test/sidestr-settings.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index cb14f91..35a8802 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ All notable changes to Podkey will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.10] - 2026-09-25 + +### Changed + +- **Sidechain spends are opt-in.** They are off until you turn them on, in the + popup's settings or in the spend window the first time a site asks; declining + answers the site with `code: 'unsupported'`. The settings list the chains in + use with the signer Podkey settled on for each, and **Forget** drops a chain's + signer and its saved state; turning spends off forgets every chain. Signer + pins from 0.0.9 carry over and count as turned on. +- `window.nostr.sidestr.enabled` says whether spends are on, so a page can word + its own buttons. `signTransaction` is present either way. + +### Added + +- **Spends open quickly.** Podkey keeps each chain's validated state (coins, the + last headers and what each coin carries) and the next spend checks only the + blocks since: on `sidestr:dreamlab`, 545 blocks took 5.9 s to validate the + first time and 0.3 s after. A saved state whose block hash no longer matches + the mirror (a reset chain) is dropped and every block is checked again; the + spend window says which it did. + ## [0.0.9] - 2026-09-24 ### Added diff --git a/README.md b/README.md index 7e609bb..199dcbf 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > Browser extension for **did:nostr** and **Solid** authentication -[![Version](https://img.shields.io/badge/version-0.0.9-blue.svg)](https://github.com/JavaScriptSolidServer/podkey/actions/workflows/ci.yml?query=branch%3Amain+is%3Asuccess) +[![Version](https://img.shields.io/badge/version-0.0.10-blue.svg)](https://github.com/JavaScriptSolidServer/podkey/actions/workflows/ci.yml?query=branch%3Amain+is%3Asuccess) [![License](https://img.shields.io/badge/license-AGPL--3.0-green.svg)](LICENSE) [![NIP-07](https://img.shields.io/badge/NIP--07-compatible-purple.svg)](https://github.com/nostr-protocol/nips/blob/master/07.md) [![Test Page](https://img.shields.io/badge/test--page-live-brightgreen)](https://javascriptsolidserver.github.io/podkey/test-page/) @@ -154,6 +154,14 @@ only after you confirm. It takes chains beside test networks only for now, and remembers each chain's signer on the first spend so that a different signer announcing the same id is refused. +Sidechain spends are **off until you turn them on**, in Podkey's settings or in +the spend window the first time a site asks (declining answers the site with +`code: 'unsupported'`). `window.nostr.sidestr.enabled` tells a page which state +it is in, so it can word its own buttons; the method is there either way. +Podkey keeps each chain's validated state, so the next spend checks only the +blocks since; the settings list the chains in use, and **Forget** drops a +chain's signer and its saved state. Turning spends off forgets every chain. + ## Architecture ``` diff --git a/manifest.json b/manifest.json index 76625aa..9429939 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Podkey", - "version": "0.0.9", + "version": "0.0.10", "description": "Nostr (NIP-07) signer and Solid (NIP-98) login. One did:nostr key, encrypted on your device.", "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'" diff --git a/package.json b/package.json index 99c2569..6abb9f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "podkey", - "version": "0.0.9", + "version": "0.0.10", "description": "did:nostr and Solid authentication extension - NIP-07 provider for decentralized identity", "main": "src/index.js", "type": "module", diff --git a/popup/popup.css b/popup/popup.css index 6c9de6b..09a54fd 100644 --- a/popup/popup.css +++ b/popup/popup.css @@ -702,6 +702,11 @@ input:focus-visible + .slider { color: var(--text-faint); } +/* ---- Sidechain chains in use (under the setting) ---- */ +.sidestr-chains { + margin-top: 8px; +} + /* ---- Version: quiet, on every screen ---- */ .version { text-align: center; diff --git a/popup/popup.html b/popup/popup.html index f4c2b89..175460a 100644 --- a/popup/popup.html +++ b/popup/popup.html @@ -294,6 +294,17 @@

Podkey

+
+
+
Sidechain spends
+
Let sites ask Podkey to sign spends on sidestr chains (test coins). Podkey shows every spend and asks each time. Turning this off forgets every chain.
+
+ +
+
diff --git a/popup/popup.js b/popup/popup.js index 6f50061..79efbcd 100644 --- a/popup/popup.js +++ b/popup/popup.js @@ -13,6 +13,7 @@ import { wrapPrivateKey } from '../src/passkey.js'; import { hexToNsec } from '../src/keyformat.js'; +import * as sidestrSettings from '../src/sidestr/settings.js'; // Set DEBUG=true to log identity material (public key / DID) for local // debugging. Off by default so the popup never prints the user's pubkey. @@ -246,6 +247,8 @@ async function showMainScreen(status) { const { podkey_auto_sign: autoSign = false } = await chrome.storage.local.get(['podkey_auto_sign']); document.getElementById('autoSignToggle').checked = autoSign; + await loadSidestrSettings(); + const config = await getPasskeyConfig(); const passkeyBtn = document.getElementById('enablePasskeyBtn'); passkeyBtn.hidden = config?.mode === 'derived'; @@ -294,6 +297,7 @@ function setupEventListeners() { // Main screen document.getElementById('copyBtn').addEventListener('click', handleCopy); document.getElementById('autoSignToggle').addEventListener('change', handleAutoSignToggle); + document.getElementById('sidestrToggle').addEventListener('change', handleSidestrToggle); document.getElementById('exportBtn').addEventListener('click', handleExport); document.getElementById('lockBtn').addEventListener('click', handleLock); // Same wipe-and-return-to-setup action as the unlock screen's link, surfaced @@ -650,6 +654,45 @@ async function handleAutoSignToggle(event) { console.log('[Podkey] Auto-sign:', enabled); } +/** + * Sidechain spends (sidestr): off until turned on, here or in the spend window + * when a site first asks. Lists the chains in use with the signer Podkey + * settled on for each; Forget drops a chain's pin and cached state. + */ +async function loadSidestrSettings() { + const s = await sidestrSettings.load(chrome.storage.local); + document.getElementById('sidestrToggle').checked = s.enabled; + const listEl = document.getElementById('sidestrChains'); + const ids = Object.keys(s.chains).sort(); + listEl.replaceChildren(); + listEl.hidden = !s.enabled || ids.length === 0; + for (const chainId of ids) { + const row = document.createElement('div'); + row.className = 'trusted-item'; + const label = document.createElement('span'); + label.className = 'trusted-origin'; + const signer = s.chains[chainId].signer; + label.textContent = `${chainId} · signer ${signer.slice(0, 8)}…`; + label.title = `${chainId}, signer ${signer}`; + const btn = document.createElement('button'); + btn.className = 'btn-remove'; + btn.textContent = 'Forget'; + btn.addEventListener('click', async () => { + await sidestrSettings.forgetChain(chrome.storage.local, chainId); + await loadSidestrSettings(); + }); + row.appendChild(label); + row.appendChild(btn); + listEl.appendChild(row); + } +} + +async function handleSidestrToggle(event) { + if (event.target.checked) await sidestrSettings.enable(chrome.storage.local); + else await sidestrSettings.disable(chrome.storage.local); + await loadSidestrSettings(); +} + /** * Handle export private key */ diff --git a/popup/spend.css b/popup/spend.css index 5ad80bd..bf17be5 100644 --- a/popup/spend.css +++ b/popup/spend.css @@ -41,6 +41,19 @@ body { margin: 8px 0 10px; } +/* ---- Opt-in ---- */ +.opt-in-list { + margin: 4px 0 12px; + padding-left: 18px; + font-size: 12.5px; + line-height: 1.5; + color: var(--text); +} + +.opt-in-list li + li { + margin-top: 4px; +} + /* ---- Loading steps ---- */ .steps { list-style: none; diff --git a/popup/spend.html b/popup/spend.html index a3317fb..59e9d6d 100644 --- a/popup/spend.html +++ b/popup/spend.html @@ -29,6 +29,21 @@ … + + +
Podkey is checking the chain itself
diff --git a/popup/spend.js b/popup/spend.js index 4e0eb21..d78b8b4 100644 --- a/popup/spend.js +++ b/popup/spend.js @@ -12,8 +12,10 @@ */ import * as core from '../src/sidestr/core.js'; +import * as settings from '../src/sidestr/settings.js'; -const PINS_KEY = 'podkey_sidestr_signers'; +const storage = chrome.storage.local; +const cache = settings.cacheStore(storage); const PROFILE_KIND = 0; const $ = (id) => document.getElementById(id); const id = new URLSearchParams(location.search).get('id'); @@ -116,7 +118,11 @@ function render ({ req, opened, reviewed, pinned, lib }) { $('chainId').textContent = ex.chain.id; $('network').textContent = parent.mainnet ? parent.label : `Test coins · ${parent.label}`; - $('chainCheck').textContent = `✓ ${(ex.tip().height + 1).toLocaleString('en-GB')} blocks checked by Podkey · ${mirrorNote}`; + const blocks = ex.tip().height + 1; + const resumed = opened.fromCache; + $('chainCheck').textContent = resumed == null + ? `✓ ${blocks.toLocaleString('en-GB')} blocks checked by Podkey · ${mirrorNote}` + : `✓ ${(blocks - resumed - 1).toLocaleString('en-GB')} new blocks checked by Podkey, ${(resumed + 1).toLocaleString('en-GB')} from your last visit · ${mirrorNote}`; $('signer').textContent = short(signer, 8, 8); $('signer').title = signer; const pin = $('signerPin'); @@ -211,11 +217,7 @@ async function doSign ({ req, opened, reviewed, pinned, lib }) { try { const { signatures } = await send({ type: 'SIDESTR_SIGN_DIGESTS', id, digests: reviewed.sighashes.map((s) => s.digest) }); const result = core.finish({ ex: opened.ex, lib, reviewed, signatures, pub: req.pub }); - if (!pinned) { - const pins = (await chrome.storage.local.get(PINS_KEY))[PINS_KEY] ?? {}; - pins[req.chain] = opened.signer; - await chrome.storage.local.set({ [PINS_KEY]: pins }); - } + if (!pinned) await settings.pinChain(storage, req.chain, opened.signer); finishWith({ result }); $('review').hidden = true; $('countdownWrap').hidden = true; $('signed').hidden = false; setTimeout(() => window.close(), 1200); @@ -247,9 +249,7 @@ function fail (e) { $('forgetAck').addEventListener('change', () => { $('forget').disabled = !$('forgetAck').checked; }); $('forget').addEventListener('click', async () => { const req = await send({ type: 'SIDESTR_REQUEST', id }); - const pins = (await chrome.storage.local.get(PINS_KEY))[PINS_KEY] ?? {}; - delete pins[req.chain]; - await chrome.storage.local.set({ [PINS_KEY]: pins }); + await settings.forgetChain(storage, req.chain); $('failure').hidden = true; $('loading').hidden = false; main(); }); @@ -259,23 +259,63 @@ $('reject').addEventListener('click', () => { window.close(); }); +// ---- opt-in --------------------------------------------------------------- + +// Spends are off until the person turns them on. A site that asks while they +// are off gets this, in Podkey's own window, rather than a dead end. +function askToTurnOn () { + return new Promise((resolve) => { + $('loading').hidden = true; $('optIn').hidden = false; + $('optInYes').focus(); + $('optInYes').onclick = () => { $('optIn').hidden = true; $('loading').hidden = false; resolve(true); }; + $('optInNo').onclick = () => resolve(false); + }); +} + // ---- main ----------------------------------------------------------------- +// Open the chain, from the saved state where there is one; a saved asset view +// that does not match the explorer's saved state means both are dropped and +// every block is checked again. +async function openWithCache (lib, chainId, pinned) { + const progress = (m) => { if (/^Checking/.test(m)) step('stepCheck'); }; + let opened = await core.openChain({ lib, chainId, pinned, store: cache, onProgress: progress }); + let view; + try { + const raw = opened.fromCache == null ? null : await cache.get(core.assetCacheKey(chainId)); + view = core.assetView(opened.ex, lib, { cached: raw ? JSON.parse(raw) : null }); + } catch (e) { + if (!(e instanceof core.CacheMismatch) && !(e instanceof SyntaxError)) throw e; + await settings.clearCaches(storage, chainId); + opened = await core.openChain({ lib, chainId, pinned, store: cache, onProgress: progress }); + view = core.assetView(opened.ex, lib); + } + const saved = core.serializeView(view, opened.ex); + if (saved && opened.ex.cacheable) await cache.set(core.assetCacheKey(chainId), saved); + return { opened, view }; +} + async function main () { try { const req = await send({ type: 'SIDESTR_REQUEST', id }); $('origin').textContent = req.origin; $('loadingChain').textContent = req.chain; startCountdown(req.expiresAt); + let s = await settings.load(storage); + if (!s.enabled) { + $('optInOrigin').textContent = req.origin; + if (!(await askToTurnOn())) { + finishWith({ error: { code: 'unsupported', message: 'Sidechain spends are turned off in Podkey' } }); + window.close(); + return; + } + s = await settings.enable(storage); + } step('stepFind'); const lib = await core.loadLib(); - const pinned = ((await chrome.storage.local.get(PINS_KEY))[PINS_KEY] ?? {})[req.chain] ?? null; - const opened = await core.openChain({ - lib, chainId: req.chain, pinned, - onProgress: (m) => { if (/^Checking/.test(m)) step('stepCheck'); } - }); + const pinned = s.chains[req.chain]?.signer ?? null; + const { opened, view } = await openWithCache(lib, req.chain, pinned); step('stepReview'); - const view = core.assetView(opened.ex, lib); const reviewed = core.review({ ex: opened.ex, lib, view, pub: req.pub, txHex: req.tx }); render({ req, opened, reviewed, pinned, lib }); } catch (e) { diff --git a/src/injected.js b/src/injected.js index 6363f93..064c0fc 100644 --- a/src/injected.js +++ b/src/injected.js @@ -103,6 +103,24 @@ script.onerror = function () { }; (document.head || document.documentElement).appendChild(script); +// Mirror the sidechain-spends setting onto , +// which window.nostr.sidestr.enabled reads, and keep it current. Guarded: an +// orphaned context or a test stub may have no storage API. +function mirrorSidestrSetting (settingsValue) { + const on = settingsValue && typeof settingsValue === 'object' && settingsValue.enabled === true; + document.documentElement?.setAttribute?.('data-podkey-sidestr', on ? 'on' : 'off'); +} +try { + chrome.storage?.local?.get?.(['podkey_sidestr', 'podkey_sidestr_signers']).then((got) => { + // 0.0.9 pins count as turned on (src/sidestr/settings.js normalize) + const legacy = got?.podkey_sidestr_signers && Object.keys(got.podkey_sidestr_signers).length > 0; + mirrorSidestrSetting(got?.podkey_sidestr ?? (legacy ? { enabled: true } : null)); + }).catch(() => {}); + chrome.storage?.onChanged?.addListener?.((changes, area) => { + if (area === 'local' && changes.podkey_sidestr) mirrorSidestrSetting(changes.podkey_sidestr.newValue); + }); +} catch { /* no storage here: the attribute stays unset, which reads as off */ } + // Allowed message types that can be forwarded to the background script const ALLOWED_TYPES = new Set([ 'GET_PUBLIC_KEY', diff --git a/src/nostr-provider.js b/src/nostr-provider.js index f34d2ff..4d87a45 100644 --- a/src/nostr-provider.js +++ b/src/nostr-provider.js @@ -108,6 +108,17 @@ // what a page calls this signer in its own copy ("Podkey will show you this spend") name: 'Podkey', + /** + * Whether sidechain spends are turned on in Podkey. They are off until the + * person turns them on; while off, signTransaction still works, and its + * first call asks, in Podkey's own window. Read live: the content script + * mirrors the setting onto . A page that forges + * the attribute only misleads itself; the spend window checks the setting. + */ + get enabled () { + return globalThis.document?.documentElement?.dataset?.podkeySidestr === 'on'; + }, + /** * @param {{chain: string, tx: string}} request - chain id and the * transaction as hex (any witness is ignored and replaced) diff --git a/src/sidestr/core.js b/src/sidestr/core.js index b184988..ca919f2 100644 --- a/src/sidestr/core.js +++ b/src/sidestr/core.js @@ -53,7 +53,7 @@ export async function loadLib (base = new URL('../../vendor/sidestr/', import.me * * @returns {Promise<{ex, signer, announced, mirror, parent, mirrorNote}>} */ -export async function openChain ({ lib, chainId, pinned = null, relays = RELAYS, fetchJson, loadJson, onProgress = () => {} }) { +export async function openChain ({ lib, chainId, pinned = null, relays = RELAYS, fetchJson, loadJson, store = null, onProgress = () => {} }) { const { announce, explorer, parents, nostr } = lib; const verify = nostr.verifyNostrEvent; onProgress(`Asking ${relays.length} relays where ${chainId} is`); @@ -76,7 +76,10 @@ export async function openChain ({ lib, chainId, pinned = null, relays = RELAYS, if (parent.mainnet) throw new SpendError('unsupported', `${chainId} sits beside ${parent.label}. Podkey signs only for chains beside test networks for now.`); onProgress(`Checking every block of ${chainId}`); - const ex = new explorer.Explorer(found.mirror, loadJson ? { loadJson } : {}); + // With a store the explorer resumes from the state it validated last time and + // checks only the blocks since; it drops that state itself when the mirror's + // block at the saved height has another hash (a reset chain). + const ex = new explorer.Explorer(found.mirror, { ...(loadJson ? { loadJson } : {}), ...(store ? { store } : {}) }); try { await ex.open(); } catch (e) { @@ -91,28 +94,51 @@ export async function openChain ({ lib, chainId, pinned = null, relays = RELAYS, const tip = ex.tip(); const judged = announce.judgeMirror({ announced: found.tip, height: tip.height, headerHex: ex.headerHex(tip.height) }); if (judged.ok === false) throw new SpendError('unavailable', `The mirror contradicts ${chainId}'s signer: ${judged.note}`); - return { ex, signer: found.tip.pubkey, announced: found.tip, mirror: found.mirror, parent, mirrorNote: judged.note }; + return { ex, signer: found.tip.pubkey, announced: found.tip, mirror: found.mirror, parent, mirrorNote: judged.note, fromCache: ex.fromCache ?? null }; } const outpoint = (inp) => `${inp.prevout.txid}:${inp.prevout.vout}`; +/** Bumped when a saved asset view could be wrong. */ +export const ASSET_CACHE_VERSION = 1; +/** The cache key an asset view is saved under, per chain. */ +export const assetCacheKey = (chainId) => `assets:${chainId}`; + +/** Thrown when a saved asset view does not match the explorer's resumed state. */ +export class CacheMismatch extends Error {} + /** * What each unspent output carries (SPEC 12). On a chain that names the * `assets` rule this is the rule's own state. On one that names no rules it is * the holders' reading of the same records: a transaction that breaks the rule * keeps its spends and carries nothing, so what it tried to move is destroyed * (the view sidestr-core's AssetView takes, so the two agree). + * + * `cached` is a view saved by serializeView. When the explorer resumed from its + * own saved state (ex.fromCache), the view resumes from `cached` at that same + * height and block hash and reads only the blocks since; anything else throws + * CacheMismatch, and the caller validates in full. */ -export function assetView (ex, lib) { +export function assetView (ex, lib, { cached = null } = {}) { const { assets } = lib; if (ex.rules?.assets) { const r = ex.rules.assets; return { mode: 'rule', carried: r.carried, issued: r.issued, check: r.check, CarryView: assets.CarryView }; } const ov = assets.assetsOverlay(ex.chain); - for (let h = 0; h < ex.blocks.length; h++) { + let start = 0; + if (ex.fromCache != null) { + const at = ex.blocks[ex.fromCache]; + if (!cached || cached.v !== ASSET_CACHE_VERSION || cached.chain !== ex.chain.id || cached.height !== ex.fromCache || !at || cached.hash !== at.hash) { + throw new CacheMismatch(`No saved asset view for ${ex.chain.id} at ${ex.fromCache}`); + } + for (const [k, m] of cached.carried) ov.carried.set(k, new Map(m)); + for (const [id, i] of cached.issued) ov.issued.set(id, i); + start = ex.fromCache + 1; + } + for (let h = start; h < ex.blocks.length; h++) { const b = ex.blocks[h]; - if (!b?.block) throw new SpendError('unavailable', `Block ${h} is not in memory; the asset view needs every block.`); + if (!b?.block) throw new SpendError('unavailable', `Block ${h} is not in memory; the asset view needs every block since the last visit.`); const view = new assets.CarryView(ov.carried); b.block.transactions.forEach((tx, i) => { if (i === 0) return; // a coinbase carries nothing @@ -127,6 +153,17 @@ export function assetView (ex, lib) { return { mode: 'view', carried: ov.carried, issued: ov.issued, check: ov.check, CarryView: assets.CarryView }; } +/** A holders' view, as JSON to save at the explorer's tip; null for a chain whose rule holds the state. */ +export function serializeView (view, ex) { + if (view.mode !== 'view') return null; + const tip = ex.tip(); + return JSON.stringify({ + v: ASSET_CACHE_VERSION, chain: ex.chain.id, height: tip.height, hash: tip.hash, + carried: [...view.carried].map(([k, m]) => [k, [...m]]), + issued: [...view.issued] + }); +} + const addAll = (into, from) => { for (const [a, n] of from ?? []) into.set(a, (into.get(a) ?? 0) + n); return into; }; /** An asset amount in its own units, e.g. 1.50 for 150 at 2 decimals. */ diff --git a/src/sidestr/settings.js b/src/sidestr/settings.js new file mode 100644 index 0000000..ec46875 --- /dev/null +++ b/src/sidestr/settings.js @@ -0,0 +1,96 @@ +/** + * Podkey - sidestr settings: opt-in, the chains in use, and the cache + * + * Sidechain spends are off until the person turns them on, either in the + * popup's settings or in the spend window when a site first asks. Each chain + * used is listed with the signer Podkey settled on the first time (a chain id + * is a name, not a proof), and its validated state is cached so the next spend + * checks only the blocks since. Forgetting a chain, or turning spends off, + * drops its pin and its cache. + * + * `storage` is chrome.storage.local, or anything with the same + * get/set/remove shape, so Node can test this. + */ + +/** The settings: { enabled, chains: { [chain id]: { signer, addedAt } } }. */ +export const SETTINGS_KEY = 'podkey_sidestr'; +/** 0.0.9 stored pins here, before spends were opt-in. */ +export const LEGACY_PINS_KEY = 'podkey_sidestr_signers'; +/** Every cache entry's storage key starts with this. */ +export const CACHE_PREFIX = 'podkey_sidestr_cache:'; + +const HEX64 = /^[0-9a-f]{64}$/; + +/** + * The settings from what storage holds. Pins from 0.0.9 carry over, and count + * as having turned spends on: the person already approved a spend there. + */ +export function normalize (raw, legacyPins) { + const chains = {}; + for (const [id, entry] of Object.entries(raw?.chains ?? {})) { + if (HEX64.test(entry?.signer ?? '')) chains[id] = { signer: entry.signer, addedAt: Number(entry.addedAt) || 0 }; + } + let enabled = raw?.enabled === true; + for (const [id, signer] of Object.entries(legacyPins ?? {})) { + if (!chains[id] && HEX64.test(signer ?? '')) { chains[id] = { signer, addedAt: 0 }; enabled = true; } + } + return { enabled, chains }; +} + +export async function load (storage) { + const got = await storage.get([SETTINGS_KEY, LEGACY_PINS_KEY]); + return normalize(got[SETTINGS_KEY], got[LEGACY_PINS_KEY]); +} + +async function save (storage, settings) { + await storage.set({ [SETTINGS_KEY]: settings }); + await storage.remove(LEGACY_PINS_KEY); + return settings; +} + +/** Turn sidechain spends on. */ +export async function enable (storage) { + const s = await load(storage); + return save(storage, { ...s, enabled: true }); +} + +/** Turn sidechain spends off: every pin and every cache goes too. */ +export async function disable (storage) { + await clearCaches(storage); + return save(storage, { enabled: false, chains: {} }); +} + +/** Remember the signer Podkey settled on for a chain. */ +export async function pinChain (storage, chainId, signer, now = Date.now()) { + if (!HEX64.test(signer ?? '')) throw new Error('A signer is a 64-hex key'); + const s = await load(storage); + return save(storage, { ...s, chains: { ...s.chains, [chainId]: { signer, addedAt: s.chains[chainId]?.addedAt || now } } }); +} + +/** Forget a chain: its pinned signer and its cache. */ +export async function forgetChain (storage, chainId) { + await clearCaches(storage, chainId); + const s = await load(storage); + const chains = { ...s.chains }; delete chains[chainId]; + return save(storage, { ...s, chains }); +} + +/** Drop the cache for one chain, or for every chain. */ +export async function clearCaches (storage, chainId = null) { + const all = await storage.get(null); + const keys = Object.keys(all ?? {}).filter((k) => k.startsWith(CACHE_PREFIX) && (chainId === null || k.endsWith(`:${chainId}`))); + if (keys.length) await storage.remove(keys); +} + +/** + * The { get, set, delete } store the explorer and the asset view keep their + * validated state in, namespaced under CACHE_PREFIX. A failed write (quota) + * only means the next spend validates from the start. + */ +export function cacheStore (storage) { + return { + async get (key) { const k = CACHE_PREFIX + key; return (await storage.get(k))[k] ?? null; }, + async set (key, value) { try { await storage.set({ [CACHE_PREFIX + key]: value }); } catch { /* quota: validate in full next time */ } }, + async delete (key) { await storage.remove(CACHE_PREFIX + key); } + }; +} diff --git a/test/provider-surface.test.js b/test/provider-surface.test.js index 9862346..cb036b8 100644 --- a/test/provider-surface.test.js +++ b/test/provider-surface.test.js @@ -39,7 +39,7 @@ describe('provider surface (honest feature-detection)', () => { assert.equal(Object.isFrozen(nostr.sidestr), true); assert.equal(nostr.sidestr.version, 1); assert.equal(nostr.sidestr.name, 'Podkey'); - assert.deepEqual(Object.keys(nostr.sidestr).sort(), ['name', 'signTransaction', 'version']); + assert.deepEqual(Object.keys(nostr.sidestr).sort(), ['enabled', 'name', 'signTransaction', 'version']); assert.equal(typeof nostr.sidestr.signTransaction, 'function'); }); @@ -163,6 +163,20 @@ describe('provider request wiring (podkey-request CustomEvent)', () => { assert.deepEqual(await resP, { tx: 'ab', txid: 'c'.repeat(64) }); }); + it('sidestr.enabled reads the setting Podkey mirrors onto , live', () => { + const had = global.document; + try { + global.document = { documentElement: { dataset: {} } }; + assert.equal(nostr.sidestr.enabled, false, 'off until turned on'); + global.document.documentElement.dataset.podkeySidestr = 'on'; + assert.equal(nostr.sidestr.enabled, true); + global.document.documentElement.dataset.podkeySidestr = 'off'; + assert.equal(nostr.sidestr.enabled, false); + } finally { + global.document = had; + } + }); + it('sidestr.signTransaction rejects a non-object request with code invalid', async () => { await assert.rejects(() => nostr.sidestr.signTransaction(null), (e) => e.code === 'invalid'); }); diff --git a/test/sidestr-core.test.js b/test/sidestr-core.test.js index f57cc24..4cc4826 100644 --- a/test/sidestr-core.test.js +++ b/test/sidestr-core.test.js @@ -179,6 +179,53 @@ describe('assets (SPEC 12)', () => { }); }); +describe('the asset view resumes from a saved state', () => { + // three blocks: an issue, a transfer onward, then a further transfer + function chainOf3 () { + const rec = (t) => ({ value: 0, scriptPubKey: lib.records.recordScript(t) }); + const issue = { version: 2, lockTime: 0, witness: [], inputs: [input(T(5))], outputs: [{ value: 330, scriptPubKey: me }, rec('issue:DREAM:0'), rec('tally:self:0=1000')] }; + const id = engine.k.codec.txid(issue); + const move = { version: 2, lockTime: 0, witness: [], inputs: [input(id)], outputs: [{ value: 330, scriptPubKey: stranger }, { value: 330, scriptPubKey: me }, rec(`tally:${id}:0=100,1=900`)] }; + const moveId = engine.k.codec.txid(move); + const again = { version: 2, lockTime: 0, witness: [], inputs: [input(moveId, 1)], outputs: [{ value: 330, scriptPubKey: me }, rec(`tally:${id}:0=900`)] }; + const againId = engine.k.codec.txid(again); + const blocks = [ + { hash: 'h0', block: { transactions: [{}, issue] }, txids: ['c0', id] }, + { hash: 'h1', block: { transactions: [{}, move] }, txids: ['c1', moveId] }, + { hash: 'h2', block: { transactions: [{}, again] }, txids: ['c2', againId] } + ]; + return { blocks, id, moveId, againId }; + } + const exAt = (blocks, fromCache = null) => ({ ...makeEx({ blocks, height: blocks.length - 1 }), fromCache, tip: () => ({ height: blocks.length - 1, hash: blocks[blocks.length - 1].hash }) }); + const flat = (v) => JSON.stringify([...v.carried].map(([k, m]) => [k, [...m]]).sort()); + + it('resuming at block 1 reaches exactly the state a full replay does', () => { + const { blocks, id } = chainOf3(); + const full = core.assetView(exAt(blocks), lib); + const saved = core.serializeView(core.assetView(exAt(blocks.slice(0, 2)), lib), exAt(blocks.slice(0, 2))); + // the explorer resumed at 1: blocks 0 and 1 are placeholders, only block 2 is in memory + const resumedBlocks = [{ hash: 'h0' }, { hash: 'h1', cached: true }, blocks[2]]; + const resumed = core.assetView(exAt(resumedBlocks, 1), lib, { cached: JSON.parse(saved) }); + assert.equal(flat(resumed), flat(full)); + assert.equal(resumed.issued.get(id).ticker, 'DREAM'); + }); + + it('refuses a saved view from another height or another block, so the caller checks in full', () => { + const { blocks } = chainOf3(); + const saved = JSON.parse(core.serializeView(core.assetView(exAt(blocks.slice(0, 2)), lib), exAt(blocks.slice(0, 2)))); + const resumedBlocks = [{ hash: 'h0' }, { hash: 'h1', cached: true }, blocks[2]]; + assert.throws(() => core.assetView(exAt(resumedBlocks, 1), lib, { cached: null }), core.CacheMismatch); + assert.throws(() => core.assetView(exAt(resumedBlocks, 1), lib, { cached: { ...saved, hash: 'other' } }), core.CacheMismatch); + assert.throws(() => core.assetView(exAt(resumedBlocks, 1), lib, { cached: { ...saved, height: 0 } }), core.CacheMismatch); + assert.throws(() => core.assetView(exAt(resumedBlocks, 1), lib, { cached: { ...saved, v: 99 } }), core.CacheMismatch); + }); + + it('a chain whose rule holds the state saves nothing', () => { + const ex = { ...makeEx(), rules: { assets: { carried: new Map(), issued: new Map(), check: () => ({ ok: true, out: new Map() }) } } }; + assert.equal(core.serializeView(core.assetView(ex, lib), ex), null); + }); +}); + describe('formatAsset', () => { it('formats whole and decimal units', () => { assert.equal(core.formatAsset(1_000_000, 0), '1,000,000'); diff --git a/test/sidestr-settings.test.js b/test/sidestr-settings.test.js new file mode 100644 index 0000000..96bfbf4 --- /dev/null +++ b/test/sidestr-settings.test.js @@ -0,0 +1,92 @@ +/** + * Tests for sidestr settings (src/sidestr/settings.js): spends are off until + * turned on, 0.0.9 pins carry over as turned on, and forgetting a chain or + * turning spends off drops pins and cached state, chain by chain. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import * as settings from '../src/sidestr/settings.js'; + +const A = 'a'.repeat(64); +const B = 'b'.repeat(64); + +/** chrome.storage.local's get/set/remove over a Map. */ +function memoryStorage (initial = {}, { failSet = false } = {}) { + const m = new Map(Object.entries(initial)); + return { + m, + async get (keys) { + if (keys === null) return Object.fromEntries(m); + const list = Array.isArray(keys) ? keys : [keys]; + return Object.fromEntries(list.filter((k) => m.has(k)).map((k) => [k, m.get(k)])); + }, + async set (obj) { if (failSet) throw new Error('QUOTA_BYTES quota exceeded'); for (const [k, v] of Object.entries(obj)) m.set(k, v); }, + async remove (keys) { for (const k of Array.isArray(keys) ? keys : [keys]) m.delete(k); } + }; +} + +describe('sidestr settings', () => { + it('is off, with no chains, by default', async () => { + assert.deepEqual(await settings.load(memoryStorage()), { enabled: false, chains: {} }); + }); + + it('carries 0.0.9 pins over as turned on, and drops the old key on the next save', async () => { + const st = memoryStorage({ [settings.LEGACY_PINS_KEY]: { 'sidestr:dreamlab': A, 'sidestr:bad': 'nope' } }); + const s = await settings.load(st); + assert.equal(s.enabled, true); + assert.deepEqual(Object.keys(s.chains), ['sidestr:dreamlab']); + await settings.enable(st); + assert.equal(st.m.has(settings.LEGACY_PINS_KEY), false); + assert.equal((await settings.load(st)).chains['sidestr:dreamlab'].signer, A); + }); + + it('pins a chain once, keeping when it was first added', async () => { + const st = memoryStorage(); + await settings.enable(st); + await settings.pinChain(st, 'sidestr:x', A, 1000); + await settings.pinChain(st, 'sidestr:x', A, 2000); + assert.deepEqual((await settings.load(st)).chains['sidestr:x'], { signer: A, addedAt: 1000 }); + await assert.rejects(() => settings.pinChain(st, 'sidestr:y', 'short')); + }); + + it('forgetting a chain drops its pin and only its cache', async () => { + const st = memoryStorage(); + await settings.enable(st); + await settings.pinChain(st, 'sidestr:lab', A); + await settings.pinChain(st, 'sidestr:dreamlab', B); + const cache = settings.cacheStore(st); + await cache.set('sidestr:state:sidestr:lab', '1'); + await cache.set('assets:sidestr:lab', '2'); + await cache.set('sidestr:state:sidestr:dreamlab', '3'); + await settings.forgetChain(st, 'sidestr:lab'); + const s = await settings.load(st); + assert.deepEqual(Object.keys(s.chains), ['sidestr:dreamlab']); + assert.equal(await cache.get('assets:sidestr:lab'), null); + assert.equal(await cache.get('sidestr:state:sidestr:lab'), null); + assert.equal(await cache.get('sidestr:state:sidestr:dreamlab'), '3', 'a chain whose id ends the same is untouched'); + }); + + it('turning spends off forgets every chain and every cache', async () => { + const st = memoryStorage(); + await settings.enable(st); + await settings.pinChain(st, 'sidestr:x', A); + await settings.cacheStore(st).set('assets:sidestr:x', 'v'); + st.m.set('podkey_trusted_origins', { 'https://a.example': true }); + await settings.disable(st); + assert.deepEqual(await settings.load(st), { enabled: false, chains: {} }); + assert.deepEqual([...st.m.keys()].filter((k) => k.startsWith(settings.CACHE_PREFIX)), []); + assert.ok(st.m.has('podkey_trusted_origins'), 'other settings are untouched'); + }); + + it('the cache store namespaces keys, and a failed write is not an error', async () => { + const st = memoryStorage(); + const cache = settings.cacheStore(st); + await cache.set('k', 'v'); + assert.ok(st.m.has(settings.CACHE_PREFIX + 'k')); + assert.equal(await cache.get('k'), 'v'); + await cache.delete('k'); + assert.equal(await cache.get('k'), null); + await settings.cacheStore(memoryStorage({}, { failSet: true })).set('k', 'v'); + }); +}); From f5061c0cd86f5766308f6e19eea230d1d9707cb1 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Fri, 25 Sep 2026 11:31:18 +0000 Subject: [PATCH 2/2] fix(spend): 'Turn on and review' is the primary action, and Not now has focus Turning spends on signs nothing, so it takes the primary style rather than the danger-toned approve style Sign uses; the safe choice keeps the focus. Co-Authored-By: jjohare --- popup/spend.html | 2 +- popup/spend.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/popup/spend.html b/popup/spend.html index 59e9d6d..24cc8c5 100644 --- a/popup/spend.html +++ b/popup/spend.html @@ -40,7 +40,7 @@
- +
diff --git a/popup/spend.js b/popup/spend.js index d78b8b4..24c59a7 100644 --- a/popup/spend.js +++ b/popup/spend.js @@ -266,7 +266,7 @@ $('reject').addEventListener('click', () => { function askToTurnOn () { return new Promise((resolve) => { $('loading').hidden = true; $('optIn').hidden = false; - $('optInYes').focus(); + $('optInNo').focus(); // the safe choice is the default, as Deny is elsewhere $('optInYes').onclick = () => { $('optIn').hidden = true; $('loading').hidden = false; resolve(true); }; $('optInNo').onclick = () => resolve(false); });