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
-[](https://github.com/JavaScriptSolidServer/podkey/actions/workflows/ci.yml?query=branch%3Amain+is%3Asuccess)
+[](https://github.com/JavaScriptSolidServer/podkey/actions/workflows/ci.yml?query=branch%3Amain+is%3Asuccess)
[](LICENSE)
[](https://github.com/nostr-protocol/nips/blob/master/07.md)
[](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..24cc8c5 100644
--- a/popup/spend.html
+++ b/popup/spend.html
@@ -29,6 +29,21 @@
…
+
+
+
Sidechain spends are off
+
wants Podkey to sign a spend on a sidestr chain. Turn sidechain spends on to see it.
+
+
Sites can then ask Podkey to sign spends. Podkey shows you every spend and asks each time; nothing is signed without you.
+
Podkey reads and checks each chain itself, and keeps what it checked so the next spend opens quickly.
+
Test coins only for now. You can turn this off in Podkey's settings, which forgets every chain.
+
+
+
+
+
+
+
Podkey is checking the chain itself
diff --git a/popup/spend.js b/popup/spend.js
index 4e0eb21..24c59a7 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;
+ $('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);
+ });
+}
+
// ---- 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');
+ });
+});