@terrariumlabs/core 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terrariumlabs/core",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "A complete EVM chain inside the page, presented to your dapp as a wallet. revm in WebAssembly, real receipts, byte-identical to Anvil.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -61,7 +61,7 @@
61
61
  "@ethereumjs/util": "^10.1.3",
62
62
  "@ethereumjs/mpt": "^10.1.3",
63
63
  "@ethereumjs/rlp": "^10.0.0",
64
- "@terrariumlabs/evm": "^0.5.0"
64
+ "@terrariumlabs/evm": "^0.7.0"
65
65
  },
66
66
  "peerDependencies": {
67
67
  "viem": "^2.0.0",
package/src/devbar.ts CHANGED
@@ -1,38 +1,55 @@
1
1
  // devbar.ts — the dev overlay. Plain DOM, own styles, talks to the chain only through provider.request(), so it works
2
- // on top of any dapp (React or not) and never touches the dapp's code. Three parts: the bar (grouped controls: scenario,
3
- // chain, wallet, the scenario's own buttons, explorer / reset / hide), the transaction explorer (a panel above it listing
4
- // every transaction with its receipt, decoded call, events and revert reason, fed by `terrarium_transactions`), and the
5
- // scenario selector (`terrarium_scenarios` / `terrarium_selectScenario`) when the Worker runs a list of scenarios.
2
+ // on top of any dapp (React or not) and never touches the dapp's code. Parts: the bar (grouped controls: scenario, chain,
3
+ // wallet, the scenario's own buttons, explorer / reset / hide), the transaction explorer (a panel above it listing every
4
+ // transaction with its receipt, decoded call, events and revert reason, fed by `terrarium_transactions`), the scenario
5
+ // selector (`terrarium_scenarios` / `terrarium_selectScenario`) when the Worker runs a list, toasts for every action, and
6
+ // two keyboard shortcuts (Alt+Shift+T: the bar, Alt+Shift+X: the explorer).
6
7
  type Provider = { request(a: { method: string; params?: unknown[] }): Promise<any> };
7
8
 
8
9
  const HIDDEN_KEY = 'terrarium:devbar-hidden';
9
10
  const CSS = `
10
11
  #terrarium-devbar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 2147483000; display: flex; align-items: center; gap: 6px 14px; flex-wrap: wrap; padding: 8px 16px; background: #14231b; color: #dfe9e3; font: 12.5px/1.4 ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; font-variant-numeric: tabular-nums; box-shadow: 0 -1px 0 rgba(255,255,255,0.08); }
11
- #terrarium-devbar[hidden], #terrarium-explorer[hidden], #terrarium-devbar-show[hidden] { display: none; }
12
+ #terrarium-devbar[hidden], #terrarium-explorer[hidden], #terrarium-devbar-show[hidden], #terrarium-devbar-menu[hidden] { display: none; }
13
+ #terrarium-devbar *:focus-visible, #terrarium-explorer *:focus-visible, #terrarium-devbar-show:focus-visible { outline: 2px solid #e8c547; outline-offset: 1px; }
12
14
  #terrarium-devbar .brand { display: flex; align-items: center; gap: 8px; }
13
15
  #terrarium-devbar .tag { background: #e8c547; color: #14231b; font-weight: 700; padding: 2px 8px; border-radius: 6px; letter-spacing: 0.01em; }
14
16
  #terrarium-devbar .name { color: #fff; font-weight: 600; }
15
17
  #terrarium-devbar select { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.14); color: #fff; padding: 5px 8px; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer; max-width: 260px; }
16
- #terrarium-devbar .status { color: rgba(223,233,227,0.72); white-space: nowrap; }
18
+ #terrarium-devbar .status { display: flex; align-items: center; gap: 10px; color: rgba(223,233,227,0.72); white-space: nowrap; }
17
19
  #terrarium-devbar .status b { color: #fff; font-weight: 600; }
18
20
  #terrarium-devbar .status .warn { color: #e8c547; }
21
+ #terrarium-devbar .status .shift { color: #e8c547; font-weight: 600; }
19
22
  #terrarium-devbar .spacer { flex: 1; min-width: 8px; }
20
- #terrarium-devbar .group { display: flex; align-items: center; gap: 4px; padding-left: 10px; border-left: 1px solid rgba(255,255,255,0.12); }
23
+ #terrarium-devbar .group { display: flex; align-items: center; gap: 4px; padding-left: 10px; border-left: 1px solid rgba(255,255,255,0.12); position: relative; }
21
24
  #terrarium-devbar .group[hidden] { display: none; }
22
25
  #terrarium-devbar .glabel { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: rgba(223,233,227,0.45); margin-right: 4px; user-select: none; }
23
26
  #terrarium-devbar .glabel:empty { display: none; }
24
- #terrarium-devbar button { background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.12); color: #fff; padding: 5px 9px; border-radius: 8px; font: inherit; cursor: pointer; white-space: nowrap; }
27
+ @media (max-width: 1280px) { #terrarium-devbar .glabel { display: none; } #terrarium-devbar { gap: 6px 10px; padding: 6px 12px; } }
28
+ #terrarium-devbar button, #terrarium-devbar-menu button { background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.12); color: #fff; padding: 5px 9px; border-radius: 8px; font: inherit; cursor: pointer; white-space: nowrap; }
25
29
  #terrarium-devbar button:hover { background: rgba(255,255,255,0.15); }
26
30
  #terrarium-devbar button.on { background: #1f6f5c; border-color: #2b8a73; }
27
31
  #terrarium-devbar button.armed { background: #7a3b2a; border-color: #b3452c; }
28
32
  #terrarium-devbar button.danger { border-color: rgba(255,140,110,0.4); color: #ffb5a0; }
33
+ #terrarium-devbar button.danger.armed { background: #b3452c; border-color: #ff8c6e; color: #fff; }
29
34
  #terrarium-devbar button.quiet { background: transparent; border-color: transparent; color: rgba(223,233,227,0.6); }
30
35
  #terrarium-devbar button.quiet:hover { color: #fff; background: rgba(255,255,255,0.08); }
36
+ #terrarium-devbar button .badge { display: inline-block; margin-left: 6px; padding: 0 6px; border-radius: 999px; background: rgba(255,255,255,0.14); font-size: 11px; line-height: 16px; }
37
+ #terrarium-devbar button .badge.bad { background: #b3452c; }
38
+ #terrarium-devbar-menu { position: fixed; z-index: 2147483001; display: flex; flex-direction: column; gap: 2px; padding: 4px; background: #1b2e24; color: #dfe9e3; border: 1px solid rgba(255,255,255,0.14); border-radius: 10px; box-shadow: 0 6px 24px rgba(0,0,0,0.4); font: 12.5px/1.4 ui-sans-serif, system-ui, sans-serif; }
39
+ #terrarium-devbar-menu button { text-align: left; background: transparent; border-color: transparent; }
40
+ #terrarium-devbar-menu button:hover { background: rgba(255,255,255,0.1); }
31
41
  #terrarium-devbar-show { position: fixed; right: 12px; bottom: 12px; z-index: 2147483000; width: 36px; height: 36px; border-radius: 50%; border: 1px solid rgba(255,255,255,0.2); background: #14231b; color: #e8c547; font: 16px/1 ui-sans-serif, system-ui, sans-serif; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.35); }
42
+ #terrarium-toast { position: fixed; right: 16px; z-index: 2147483001; display: flex; flex-direction: column; gap: 6px; align-items: flex-end; pointer-events: none; font: 12.5px/1.4 ui-sans-serif, system-ui, sans-serif; }
43
+ #terrarium-toast div { background: #1b2e24; color: #dfe9e3; border: 1px solid rgba(255,255,255,0.14); border-left: 3px solid #e8c547; border-radius: 8px; padding: 6px 10px; box-shadow: 0 4px 16px rgba(0,0,0,0.35); opacity: 1; transition: opacity 0.3s; max-width: 420px; }
44
+ #terrarium-toast div.bad { border-left-color: #ff8c6e; }
45
+ #terrarium-toast div.fade { opacity: 0; }
32
46
  #terrarium-explorer { position: fixed; left: 0; right: 0; z-index: 2147483000; max-height: 60vh; overflow: auto; background: #0f1a14; color: #dfe9e3; border-top: 1px solid rgba(255,255,255,0.12); font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-variant-numeric: tabular-nums; }
33
- #terrarium-explorer .head { display: flex; align-items: center; gap: 12px; padding: 8px 16px; font: 13px ui-sans-serif, system-ui, sans-serif; color: rgba(223,233,227,0.8); position: sticky; top: 0; background: #0f1a14; border-bottom: 1px solid rgba(255,255,255,0.08); }
47
+ #terrarium-explorer.tall { max-height: 90vh; }
48
+ #terrarium-explorer .head { display: flex; align-items: center; gap: 10px; padding: 8px 16px; font: 13px ui-sans-serif, system-ui, sans-serif; color: rgba(223,233,227,0.8); position: sticky; top: 0; background: #0f1a14; border-bottom: 1px solid rgba(255,255,255,0.08); }
34
49
  #terrarium-explorer .head b { color: #fff; }
35
- #terrarium-explorer .head select { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.14); color: #fff; padding: 3px 6px; border-radius: 6px; font: inherit; }
50
+ #terrarium-explorer .head select, #terrarium-explorer .head input, #terrarium-explorer .head button { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.14); color: #fff; padding: 3px 8px; border-radius: 6px; font: inherit; }
51
+ #terrarium-explorer .head input { width: 220px; } #terrarium-explorer .head input::placeholder { color: rgba(223,233,227,0.45); }
52
+ #terrarium-explorer .head button { cursor: pointer; } #terrarium-explorer .head button:hover { background: rgba(255,255,255,0.15); }
36
53
  #terrarium-explorer table { width: 100%; border-collapse: collapse; }
37
54
  #terrarium-explorer th { text-align: left; font-weight: 600; color: rgba(223,233,227,0.6); padding: 6px 12px; border-bottom: 1px solid rgba(255,255,255,0.08); white-space: nowrap; }
38
55
  #terrarium-explorer th:first-child, #terrarium-explorer td:first-child { padding-left: 16px; }
@@ -42,6 +59,7 @@ const CSS = `
42
59
  #terrarium-explorer tr.tx.open td { background: rgba(255,255,255,0.06); border-bottom-color: transparent; }
43
60
  #terrarium-explorer .ok { color: #7fd3a8; } #terrarium-explorer .bad { color: #ff9c80; } #terrarium-explorer .wait { color: #e8c547; }
44
61
  #terrarium-explorer .hash, #terrarium-explorer .addr { color: #9ecbff; }
62
+ #terrarium-explorer .hash { cursor: copy; } #terrarium-explorer .hash:hover { text-decoration: underline dotted; }
45
63
  #terrarium-explorer .name { color: #e8c547; }
46
64
  #terrarium-explorer .dim { color: rgba(223,233,227,0.55); }
47
65
  #terrarium-explorer .detail td { padding: 10px 16px 14px 40px; white-space: normal; background: rgba(0,0,0,0.25); }
@@ -66,6 +84,8 @@ export const formatEth = (hex: string | null | undefined) => {
66
84
  const formatGwei = (hex: string | null | undefined) => { if (!hex) return '0'; const wei = BigInt(hex), whole = wei / 10n ** 9n, frac = (wei % 10n ** 9n).toString().padStart(9, '0').slice(0, 3).replace(/0+$/, ''); return frac ? `${whole}.${frac}` : whole.toString(); };
67
85
  const fmtArgs = (args: any): string => Array.isArray(args) ? args.map(fmtArgs).join(', ') : args && typeof args === 'object' ? Object.entries(args).map(([k, v]) => `${k}: ${fmtArgs(v)}`).join(', ') : String(args);
68
86
  const time = (hex: string | null) => (hex ? new Date(Number(BigInt(hex)) * 1000).toLocaleTimeString() : '');
87
+ /** a duration in seconds as "+1h 30m" / "-2d" */
88
+ const span = (s: number) => { const a = Math.abs(s), sign = s < 0 ? '-' : '+'; if (a < 3600) return `${sign}${Math.round(a / 60)}m`; if (a < 86400) { const h = Math.floor(a / 3600), m = Math.round((a % 3600) / 60); return `${sign}${h}h${m ? ` ${m}m` : ''}`; } const d = Math.floor(a / 86400), h = Math.round((a % 86400) / 3600); return `${sign}${d}d${h ? ` ${h}h` : ''}`; };
69
89
 
70
90
  export interface DevBarOptions {
71
91
  /** start collapsed to the leaf (default false). A Hide/Show click is remembered in localStorage and overrides this */
@@ -76,69 +96,90 @@ export function mountDevBar(provider: Provider, opts: DevBarOptions = {}) {
76
96
  if (document.getElementById('terrarium-devbar')) return;
77
97
  const rpc = (method: string, params: unknown[] = []) => provider.request({ method, params });
78
98
  const bar = document.createElement('footer');
79
- bar.id = 'terrarium-devbar'; bar.dataset.testid = 'devbar';
99
+ bar.id = 'terrarium-devbar'; bar.dataset.testid = 'devbar'; bar.setAttribute('role', 'toolbar'); bar.setAttribute('aria-label', 'Terrarium dev bar');
80
100
  const style = document.createElement('style'); style.textContent = CSS;
81
101
  bar.append(style);
82
102
  const el = (html: string) => { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild as HTMLElement; };
83
- const btn = (label: string, testid: string, title: string, onClick: () => Promise<unknown> | void) => { const b = el(`<button data-testid="${testid}" title="${esc(title)}">${label}</button>`); b.onclick = () => Promise.resolve(onClick()).catch((e) => console.warn('[terrarium]', e)); return b; };
84
- const group = (label: string, testid: string, ...children: HTMLElement[]) => { const g = el(`<div class="group" data-testid="${testid}"><span class="glabel">${label}</span></div>`); g.append(...children); return g; };
103
+ const btn = (label: string, testid: string, title: string, onClick: () => Promise<unknown> | void) => { const b = el(`<button data-testid="${testid}" title="${esc(title)}" aria-label="${esc(title)}">${label}</button>`); b.onclick = () => Promise.resolve(onClick()).catch((e) => { console.warn('[terrarium]', e); toast(e?.message ?? String(e), true); }); return b; };
104
+ const group = (label: string, testid: string, ...children: HTMLElement[]) => { const g = el(`<div class="group" data-testid="${testid}" role="group" aria-label="${esc(label || 'tools')}"><span class="glabel">${label}</span></div>`); g.append(...children); return g; };
105
+
106
+ // ---- toasts: every action says what it did --------------------------------------------------------------------------------
107
+ const toasts = el('<div id="terrarium-toast" data-testid="toast" aria-live="polite"></div>');
108
+ const toast = (msg: string, bad = false) => {
109
+ const t = el(`<div${bad ? ' class="bad"' : ''}>${esc(msg)}</div>`); toasts.append(t);
110
+ setTimeout(() => { t.classList.add('fade'); setTimeout(() => t.remove(), 350); }, bad ? 5000 : 2200);
111
+ };
85
112
 
86
113
  // ---- brand + scenario selector + status ---------------------------------------------------------------------------
87
114
  const brand = el(`<span class="brand"><span class="tag">Terrarium</span><span class="name" data-testid="scenario-name" hidden></span></span>`);
88
- const scenarioSelect = el(`<select data-testid="scenario" title="Which scenario the chain runs: switching stores the choice and reloads the page; each scenario keeps its own chain" hidden></select>`) as HTMLSelectElement;
115
+ const scenarioSelect = el(`<select data-testid="scenario" aria-label="Scenario" title="Which scenario the chain runs: switching stores the choice and reloads the page; each scenario keeps its own chain" hidden></select>`) as HTMLSelectElement;
89
116
  brand.append(scenarioSelect);
90
117
  let scenariosKey = '';
91
- scenarioSelect.onchange = () => rpc('terrarium_selectScenario', [scenarioSelect.value]).catch((e) => console.warn('[terrarium]', e));
92
- const status = el(`<span class="status">block <b data-testid="block" data-f="block">…</b> · chain <span data-f="chain">…</span><span data-f="engine"></span></span>`);
118
+ scenarioSelect.onchange = () => { toast(`Switching to “${scenarioSelect.value}”…`); rpc('terrarium_selectScenario', [scenarioSelect.value]).catch((e) => { console.warn('[terrarium]', e); toast(e?.message ?? String(e), true); }); };
119
+ const status = el(`<span class="status"><span>block <b data-testid="block" data-f="block">…</b></span><span>chain <span data-f="chain">…</span></span><span data-testid="clock" data-f="clock" title="The chain's clock: the timestamp the next block gets. Yellow when it has been shifted away from wall time (deadlines and oracles see this clock, not yours)"></span><span data-f="engine"></span></span>`);
93
120
 
94
121
  // ---- chain ------------------------------------------------------------------------------------------------------------
95
- let mining: 'auto' | 'interval' = 'auto', snap: string | null = null;
122
+ let mining: 'auto' | 'interval' = 'auto', snap: { id: string; block: number } | null = null, head = 0;
96
123
  const bMining = btn('Blocks: instant', 'mining', 'Auto: a block per transaction. Interval: a block every 3s, so you can watch pending states', async () => {
97
124
  mining = mining === 'auto' ? 'interval' : 'auto';
98
125
  await (mining === 'auto' ? rpc('evm_setAutomine', [true]) : rpc('evm_setIntervalMining', [3000]));
99
126
  bMining.textContent = mining === 'auto' ? 'Blocks: instant' : 'Blocks: every 3s'; bMining.classList.toggle('on', mining === 'interval');
127
+ toast(mining === 'auto' ? 'A block per transaction again' : 'A block every 3 seconds: transactions stay pending in between');
100
128
  });
101
129
  const bSnap = btn('Snapshot', 'snapshot', 'Snapshot the chain; revert brings blocks, receipts, journal and the UI history back', async () => {
102
- if (snap) { await rpc('evm_revert', [snap]); snap = null; bSnap.textContent = 'Snapshot'; bSnap.classList.remove('on'); }
103
- else { snap = await rpc('evm_snapshot'); bSnap.textContent = 'Revert to snapshot'; bSnap.classList.add('on'); }
130
+ if (snap) { await rpc('evm_revert', [snap.id]); toast(`Reverted to block ${snap.block}`); snap = null; bSnap.textContent = 'Snapshot'; bSnap.classList.remove('on'); }
131
+ else { const id = await rpc('evm_snapshot'); snap = { id, block: head }; bSnap.textContent = `Revert to block ${head}`; bSnap.classList.add('on'); toast(`Snapshot at block ${head}: the next click reverts to it`); }
132
+ });
133
+ const timeMenu = el('<div id="terrarium-devbar-menu" data-testid="time-menu" role="menu" aria-label="Move the chain clock" hidden></div>'); // on the body: a menu inside the bar was clipped by its layout
134
+ const shift = (seconds: number, label: string, testid: string) => btn(label, testid, `Move the chain clock forward ${label.slice(1)} and mine a block`, async () => { timeMenu.hidden = true; await rpc('evm_increaseTime', [seconds]); await rpc('evm_mine'); toast(`Chain clock moved ${label}; a block sealed at the new time`); });
135
+ timeMenu.append(shift(60, '+1 minute', 'plus-minute'), shift(3600, '+1 hour', 'plus-hour'), shift(86400, '+1 day', 'plus-day'), shift(7 * 86400, '+1 week', 'plus-week'));
136
+ const bTime = btn('Time ▾', 'time', 'Move the chain clock forward: deadlines expire, oracles go stale, interest accrues', () => {
137
+ timeMenu.hidden = !timeMenu.hidden;
138
+ if (!timeMenu.hidden) { const r = bTime.getBoundingClientRect(); timeMenu.style.left = `${r.left}px`; timeMenu.style.bottom = `${window.innerHeight - r.top + 6}px`; }
104
139
  });
140
+ bTime.setAttribute('aria-haspopup', 'menu');
105
141
  const gChain = group('chain', 'group-chain',
106
- btn('Mine a block', 'mine', 'Mine one empty block', () => rpc('evm_mine')),
107
- btn('+1 hour', 'plus-hour', 'Move the chain clock forward one hour', async () => { await rpc('evm_increaseTime', [3600]); await rpc('evm_mine'); }),
108
- bMining, bSnap);
142
+ btn('Mine a block', 'mine', 'Mine one empty block', async () => { await rpc('evm_mine'); toast(`Mined block ${head + 1}`); }),
143
+ bTime, bMining, bSnap);
144
+ document.addEventListener('click', (e) => { if (!timeMenu.hidden && !bTime.contains(e.target as Node) && !timeMenu.contains(e.target as Node)) timeMenu.hidden = true; });
109
145
 
110
146
  // ---- wallet -----------------------------------------------------------------------------------------------------------
111
- const bReject = btn('Reject next tx', 'reject-next', 'The wallet rejects the next signature request (EIP-1193 error 4001), like a user hitting Cancel', () => rpc('terrarium_setWallet', [{ rejectNext: 1 }]));
112
- const bLatency = btn('Wallet: instant', 'wallet-latency', 'Make the wallet take 2 seconds to answer, like a real one', async () => { const w = await rpc('terrarium_getWallet'); await rpc('terrarium_setWallet', [{ latencyMs: w.latencyMs ? 0 : 2000 }]); });
113
- const bLag = btn('Receipts: instant', 'receipt-lag', 'Receipts appear 3 seconds after the block, like a node that has not caught up', async () => { const w = await rpc('terrarium_getWallet'); await rpc('terrarium_setWallet', [{ receiptLagMs: w.receiptLagMs ? 0 : 3000 }]); });
147
+ const bReject = btn('Reject next tx', 'reject-next', 'The wallet rejects the next signature request (EIP-1193 error 4001), like a user hitting Cancel', async () => { await rpc('terrarium_setWallet', [{ rejectNext: 1 }]); toast('The wallet will reject the next signature request'); });
148
+ const bLatency = btn('Wallet: instant', 'wallet-latency', 'Make the wallet take 2 seconds to answer, like a real one', async () => { const w = await rpc('terrarium_getWallet'); await rpc('terrarium_setWallet', [{ latencyMs: w.latencyMs ? 0 : 2000 }]); toast(w.latencyMs ? 'The wallet answers instantly again' : 'The wallet now takes 2 seconds to answer'); });
149
+ const bLag = btn('Receipts: instant', 'receipt-lag', 'Receipts appear 3 seconds after the block, like a node that has not caught up', async () => { const w = await rpc('terrarium_getWallet'); await rpc('terrarium_setWallet', [{ receiptLagMs: w.receiptLagMs ? 0 : 3000 }]); toast(w.receiptLagMs ? 'Receipts are immediate again' : 'Receipts now arrive 3 seconds after the block'); });
114
150
  const gWallet = group('wallet', 'group-wallet', bReject, bLatency, bLag);
115
151
 
116
152
  // ---- the scenario's own knobs: actors + controls ------------------------------------------------------------------------
117
- const bActors = btn('Actors off', 'actors', 'Scripted actors: other users, keepers, arbitrageurs trading on their own', () => rpc('terrarium_actors'));
153
+ const bActors = btn('Actors off', 'actors', 'Scripted actors: other users, keepers, arbitrageurs trading on their own', async () => { const on = await rpc('terrarium_actors'); toast(on ? `${actorsLabel} on: other actors are trading now` : `${actorsLabel} off`); });
154
+ let actorsLabel = 'Actors';
118
155
  const controls = el('<span class="controls" style="display:contents"></span>'); let controlsKey = '';
119
156
  const gScenario = group('scenario', 'group-scenario', bActors, controls);
120
157
 
121
158
  // ---- the transaction explorer -----------------------------------------------------------------------------------------
122
- const panel = el(`<section id="terrarium-explorer" data-testid="explorer" hidden></section>`);
159
+ const panel = el(`<section id="terrarium-explorer" data-testid="explorer" role="region" aria-label="Transactions" hidden></section>`);
123
160
  const open = new Set<string>(); // expanded rows, by hash, kept across refreshes
124
- let lastRender = '', filter: 'all' | 'mine' | 'failed' = 'all', me: string | null = null; // `me`: accounts[0] from terrarium_status
125
- const bTxs = btn('Transactions', 'txs', 'Every transaction on this chain, like a block explorer: receipt, decoded call, events, revert reason', async () => {
126
- panel.hidden = !panel.hidden; bTxs.classList.toggle('on', !panel.hidden); lastRender = '';
127
- if (!panel.hidden) await refreshTxs();
128
- });
129
- const place = () => { panel.style.bottom = `${bar.offsetHeight}px`; };
161
+ let lastRender = '', lastTxKey = '', filter: 'all' | 'mine' | 'failed' = 'all', search = '', me: string | null = null; // `me`: accounts[0] from terrarium_status; `lastTxKey`: the counts the open explorer last saw
162
+ const bTxs = btn('Transactions', 'txs', 'Every transaction on this chain, like a block explorer: receipt, decoded call, events, revert reason (Alt+Shift+X)', () => togglePanel());
163
+ const togglePanel = async (show: boolean = !!panel.hidden) => { panel.hidden = !show; bTxs.classList.toggle('on', show); lastRender = ''; if (show) { await refreshTxs(); panel.querySelector<HTMLInputElement>('[data-testid=tx-search]')?.focus(); } place(); };
164
+ const place = () => { panel.style.bottom = `${bar.offsetHeight}px`; toasts.style.bottom = `${bar.offsetHeight + (panel.hidden ? 0 : panel.offsetHeight) + 12}px`; }; // toasts sit above whatever is open
130
165
  const statusCell = (s: string) => s === 'success' ? '<span class="ok" title="success">✓</span>' : s === 'pending' ? '<span class="wait" title="pending">⏳</span>' : `<span class="bad" title="${s}">✗</span>`;
131
166
  const who = (labels: Record<string, string>, a: string | null) => a ? `<span class="addr" title="${a}">${esc(labels[a.toLowerCase()] ?? short(a))}</span>` : '<span class="dim">contract creation</span>';
167
+ const matches = (t: any, L: Record<string, string>) => {
168
+ if (!search) return true;
169
+ const q = search.toLowerCase();
170
+ const hay = [t.hash, t.from, t.to, L[t.from?.toLowerCase()], L[t.to?.toLowerCase()], t.method?.name, t.method?.selector, t.revert?.name, t.status, ...(t.logs ?? []).flatMap((l: any) => [l.decoded?.name, L[l.address?.toLowerCase()], l.address])].filter(Boolean).join(' ').toLowerCase();
171
+ return hay.includes(q);
172
+ };
132
173
  const render = (data: { total: number; labels: Record<string, string>; transactions: any[] }) => {
133
- const key = JSON.stringify([data.total, data.transactions.map((t) => [t.hash, t.status]), [...open], filter, me]);
174
+ const key = JSON.stringify([data.total, data.transactions.map((t) => [t.hash, t.status]), [...open], filter, search, me]);
134
175
  if (key === lastRender) return; lastRender = key;
135
176
  const L = data.labels ?? {};
136
- const shown = data.transactions.filter((t) => filter === 'all' ? true : filter === 'failed' ? t.status !== 'success' && t.status !== 'pending' : !!me && t.from?.toLowerCase() === me.toLowerCase());
177
+ const shown = data.transactions.filter((t) => (filter === 'all' ? true : filter === 'failed' ? t.status !== 'success' && t.status !== 'pending' : !!me && t.from?.toLowerCase() === me.toLowerCase()) && matches(t, L));
137
178
  const rows = shown.map((t) => {
138
179
  const method = t.method ? (t.method.name ? `<span class="name">${esc(t.method.name)}</span>` : `<span class="dim">${esc(t.method.selector)}</span>`) : '<span class="dim">transfer</span>';
139
180
  const isOpen = open.has(t.hash);
140
181
  const detail = !isOpen ? '' : `<tr class="detail" data-testid="tx-detail"><td colspan="10"><dl>
141
- <dt>hash</dt><dd class="hash">${esc(t.hash)}</dd>
182
+ <dt>hash</dt><dd class="hash" data-copy="${t.hash}" title="click to copy">${esc(t.hash)}</dd>
142
183
  <dt>status</dt><dd>${esc(t.status)}${t.status === 'reverted' || t.status === 'dropped' ? ` <span class="bad">· ${t.revert ? esc(t.revert.name) + '(' + esc(fmtArgs(t.revert.args)) + ')' : esc(t.error ?? '')}</span>` : ''}</dd>
143
184
  <dt>block</dt><dd>${t.receipt ? `${num(t.receipt.blockNumber)} · ${esc(time(t.timestamp))} · index ${num(t.receipt.transactionIndex)}` : 'pending'}</dd>
144
185
  <dt>from</dt><dd>${who(L, t.from)} <span class="dim">${esc(t.from)}</span></dd>
@@ -153,42 +194,65 @@ export function mountDevBar(provider: Provider, opts: DevBarOptions = {}) {
153
194
  </td></tr>`;
154
195
  return `<tr class="tx${isOpen ? ' open' : ''}" data-testid="tx-row" data-hash="${t.hash}" data-status="${t.status}">
155
196
  <td>${statusCell(t.status)}</td><td>${t.receipt ? num(t.receipt.blockNumber) : '<span class="dim">pending</span>'}</td><td class="dim">${esc(time(t.timestamp))}</td>
156
- <td class="hash" title="${t.hash}">${short(t.hash)}</td><td>${method}</td><td>${who(L, t.from)}</td><td>${who(L, t.to)}</td>
197
+ <td class="hash" data-copy="${t.hash}" title="${t.hash} (click to copy)">${short(t.hash)}</td><td>${method}</td><td>${who(L, t.from)}</td><td>${who(L, t.to)}</td>
157
198
  <td>${formatEth(t.value)} ETH</td><td class="dim">${t.receipt ? num(t.receipt.gasUsed) : ''}</td><td class="dim">${t.logs?.length ? `${t.logs.length} event${t.logs.length === 1 ? '' : 's'}` : ''}</td></tr>${detail}`;
158
199
  }).join('');
159
- panel.innerHTML = `<div class="head"><b>Transactions</b> <span>${data.total} on this chain, newest first${data.total > data.transactions.length ? ` (showing ${data.transactions.length})` : ''}${filter !== 'all' ? `, ${shown.length} ${filter === 'mine' ? 'from you' : 'failed'}` : ''}</span>
160
- <select data-testid="tx-filter" title="Which transactions to list"><option value="all"${filter === 'all' ? ' selected' : ''}>all</option><option value="mine"${filter === 'mine' ? ' selected' : ''}>mine (account #0)</option><option value="failed"${filter === 'failed' ? ' selected' : ''}>failed</option></select>
161
- <span class="spacer" style="flex:1"></span><span class="dim">click a row for the receipt, the decoded call and the events</span></div>`
162
- + (rows ? `<table><thead><tr><th></th><th>block</th><th>time</th><th>hash</th><th>method</th><th>from</th><th>to</th><th>value</th><th>gas used</th><th>events</th></tr></thead><tbody>${rows}</tbody></table>` : `<div class="empty">${data.total ? 'Nothing matches this filter.' : 'No transactions yet. Do something in the dapp and it appears here.'}</div>`);
200
+ const headEl = panel.querySelector('.head');
201
+ const headHtml = `<b>Transactions</b> <span data-testid="explorer-count">${data.total} on this chain${data.total > data.transactions.length ? ` (showing ${data.transactions.length})` : ''}${filter !== 'all' || search ? `, ${shown.length} shown` : ''}</span>
202
+ <input data-testid="tx-search" type="search" placeholder="filter: hash, address, name, method, event…" aria-label="Filter transactions" value="${esc(search)}">
203
+ <select data-testid="tx-filter" aria-label="Which transactions" title="Which transactions to list"><option value="all"${filter === 'all' ? ' selected' : ''}>all</option><option value="mine"${filter === 'mine' ? ' selected' : ''}>mine (account #0)</option><option value="failed"${filter === 'failed' ? ' selected' : ''}>failed</option></select>
204
+ <span class="spacer" style="flex:1"></span><span class="dim">click a row for the receipt, the decoded call and the events · Esc closes</span>
205
+ <button data-testid="explorer-size" title="Taller / shorter panel">${panel.classList.contains('tall') ? '⇩ shorter' : '⇧ taller'}</button>`;
206
+ const body = rows ? `<table><thead><tr><th></th><th>block</th><th>time</th><th>hash</th><th>method</th><th>from</th><th>to</th><th>value</th><th>gas used</th><th>events</th></tr></thead><tbody>${rows}</tbody></table>` : `<div class="empty">${data.total ? 'Nothing matches.' : 'No transactions yet. Do something in the dapp and it appears here.'}</div>`;
207
+ if (headEl && document.activeElement === headEl.querySelector('[data-testid=tx-search]')) {
208
+ // keep the focused search box: update its neighbours only
209
+ headEl.querySelector('[data-testid=explorer-count]')!.textContent = `${data.total} on this chain${filter !== 'all' || search ? `, ${shown.length} shown` : ''}`;
210
+ panel.querySelector('table, .empty')?.remove(); panel.insertAdjacentHTML('beforeend', body);
211
+ } else panel.innerHTML = `<div class="head">${headHtml}</div>${body}`;
163
212
  place();
164
213
  };
165
214
  panel.addEventListener('change', (e) => { const sel = e.target as HTMLSelectElement; if (sel.dataset.testid === 'tx-filter') { filter = sel.value as typeof filter; lastRender = ''; refreshTxs(); } });
215
+ panel.addEventListener('input', (e) => { const inp = e.target as HTMLInputElement; if (inp.dataset.testid === 'tx-search') { search = inp.value.trim(); lastRender = ''; refreshTxs(); } });
166
216
  panel.addEventListener('click', (e) => {
167
- const row = (e.target as HTMLElement).closest<HTMLElement>('tr.tx'); if (!row) return;
217
+ const target = e.target as HTMLElement;
218
+ const copy = target.closest<HTMLElement>('[data-copy]');
219
+ if (copy) { e.stopPropagation(); navigator.clipboard?.writeText(copy.dataset.copy!).then(() => toast(`Copied ${short(copy.dataset.copy!)}`), () => toast('Clipboard unavailable', true)); return; }
220
+ if (target.closest('[data-testid=explorer-size]')) { panel.classList.toggle('tall'); lastRender = ''; refreshTxs(); return; }
221
+ const row = target.closest<HTMLElement>('tr.tx'); if (!row) return;
168
222
  const h = row.dataset.hash!; open.has(h) ? open.delete(h) : open.add(h); lastRender = ''; refreshTxs();
169
223
  });
170
224
  const refreshTxs = async () => { const data = await rpc('terrarium_transactions', [{ limit: 200 }]).catch(() => null); if (data) render(data); };
171
225
 
172
- // ---- reset, hide / show -------------------------------------------------------------------------------------------------
173
- const bReset = btn('Reset', 'reset', 'Wipe this scenario\'s chain and boot it again from scratch', async () => { await rpc('terrarium_reset'); location.reload(); });
226
+ // ---- reset (two clicks), hide / show ------------------------------------------------------------------------------------
227
+ let resetArmed: ReturnType<typeof setTimeout> | null = null;
228
+ const bReset = btn('Reset', 'reset', 'Wipe this scenario\'s chain and boot it again from scratch (click twice)', async () => {
229
+ if (!resetArmed) { bReset.textContent = 'Really reset?'; bReset.classList.add('armed'); toast('Click Reset again within 3 seconds to wipe this scenario\'s chain'); resetArmed = setTimeout(() => { resetArmed = null; bReset.textContent = 'Reset'; bReset.classList.remove('armed'); }, 3000); return; }
230
+ clearTimeout(resetArmed); resetArmed = null; bReset.textContent = 'Resetting…';
231
+ await rpc('terrarium_reset'); location.reload();
232
+ });
174
233
  bReset.classList.add('danger');
175
- const pill = el(`<button id="terrarium-devbar-show" data-testid="show" title="Show the Terrarium dev bar" hidden>🌱</button>`);
234
+ const pill = el(`<button id="terrarium-devbar-show" data-testid="show" title="Show the Terrarium dev bar (Alt+Shift+T)" aria-label="Show the Terrarium dev bar" hidden>🌱</button>`);
176
235
  const remember = (hidden: boolean) => { try { localStorage.setItem(HIDDEN_KEY, hidden ? '1' : '0'); } catch {} };
177
- const setHidden = (hidden: boolean) => {
178
- bar.hidden = hidden; pill.hidden = !hidden;
179
- if (hidden) { panel.hidden = true; bTxs.classList.remove('on'); }
180
- document.body.style.paddingBottom = hidden ? '' : '56px';
181
- remember(hidden);
182
- };
183
- const bHide = btn('Hide', 'hide', 'Hide the dev bar (the chain keeps running); the leaf at the bottom right brings it back', () => setHidden(true));
236
+ const apply = (hidden: boolean) => { bar.hidden = hidden; pill.hidden = !hidden; if (hidden) { panel.hidden = true; bTxs.classList.remove('on'); } document.body.style.paddingBottom = hidden ? '' : '56px'; toasts.style.bottom = hidden ? '60px' : `${(bar.offsetHeight || 48) + 12}px`; };
237
+ const setHidden = (hidden: boolean) => { apply(hidden); remember(hidden); };
238
+ const bHide = btn('Hide', 'hide', 'Hide the dev bar (the chain keeps running); the leaf at the bottom right or Alt+Shift+T brings it back', () => setHidden(true));
184
239
  bHide.classList.add('quiet');
185
240
  pill.onclick = () => setHidden(false);
186
241
  const gTools = group('', 'group-tools', bTxs, bReset, bHide);
187
242
 
188
243
  bar.append(brand, status, el('<span class="spacer"></span>'), gChain, gWallet, gScenario, gTools, panel);
189
- document.body.append(bar, pill);
244
+ document.body.append(bar, pill, toasts, timeMenu);
190
245
  let startHidden = !!opts.hidden; try { const v = localStorage.getItem(HIDDEN_KEY); if (v !== null) startHidden = v === '1'; } catch {}
191
- bar.hidden = startHidden; pill.hidden = !startHidden; document.body.style.paddingBottom = startHidden ? '' : '56px'; // apply without remembering: the default is not a choice
246
+ apply(startHidden); // without remembering: the default is not a choice
247
+
248
+ // ---- keyboard: Alt+Shift+T the bar, Alt+Shift+X the explorer, Esc closes the explorer / the time menu -----------------------------
249
+ const onKey = (e: KeyboardEvent) => {
250
+ if (!document.getElementById('terrarium-devbar')) { document.removeEventListener('keydown', onKey); return; }
251
+ if (e.altKey && e.shiftKey && (e.code === 'KeyT')) { e.preventDefault(); setHidden(!bar.hidden); }
252
+ else if (e.altKey && e.shiftKey && (e.code === 'KeyX')) { e.preventDefault(); if (bar.hidden) setHidden(false); togglePanel(); }
253
+ else if (e.key === 'Escape') { if (!panel.hidden) togglePanel(false); timeMenu.hidden = true; }
254
+ };
255
+ document.addEventListener('keydown', onKey);
192
256
 
193
257
  // ---- polling: status every 500 ms, the explorer while open, the scenario list now and then -----------------------------------
194
258
  const refreshScenarios = async () => {
@@ -205,32 +269,42 @@ export function mountDevBar(provider: Provider, opts: DevBarOptions = {}) {
205
269
  };
206
270
  const refresh = async () => {
207
271
  const s = await rpc('terrarium_status').catch(() => null); if (!s) return;
208
- me = s.accounts?.[0] ?? me;
272
+ me = s.accounts?.[0] ?? me; head = parseInt(s.block, 16); actorsLabel = s.actorsLabel ?? actorsLabel;
209
273
  status.querySelector('[data-f=chain]')!.textContent = String(s.chainId);
210
- status.querySelector('[data-f=block]')!.textContent = String(parseInt(s.block, 16));
274
+ status.querySelector('[data-f=block]')!.textContent = String(head);
275
+ if (s.now) {
276
+ const chainNow = Number(BigInt(s.now)), drift = chainNow - Math.floor(Date.now() / 1000);
277
+ const clock = status.querySelector<HTMLElement>('[data-f=clock]')!;
278
+ clock.innerHTML = Math.abs(drift) > 90 ? `clock ${esc(new Date(chainNow * 1000).toLocaleString())} <span class="shift">${esc(span(drift))}</span>` : `clock ${esc(new Date(chainNow * 1000).toLocaleTimeString())}`;
279
+ }
211
280
  const notes: string[] = [];
212
281
  if (s.fork) notes.push(`fork @${s.fork.blockNumber}${s.fork.offline ? ' offline' : ''}${s.fork.misses ? ` · <span class="warn">${s.fork.misses} MISSES</span>` : ''}`);
213
282
  if (s.http?.routes) notes.push(`${s.http.routes} HTTP route${s.http.routes === 1 ? '' : 's'}, ${s.http.hits} answered`);
214
283
  if (s.restoredFromPersistence) notes.push(`${s.localBlocks} block${s.localBlocks === 1 ? '' : 's'} restored`);
215
- status.querySelector('[data-f=engine]')!.innerHTML = notes.length ? ' · ' + notes.join(' · ') : '';
284
+ status.querySelector('[data-f=engine]')!.innerHTML = notes.map((n) => `<span>${n}</span>`).join('');
216
285
  status.title = `revm/wasm · the chain persists in IndexedDB${s.restoredFromPersistence ? ' (Reset to start clean)' : ''}`;
286
+ const txKey = JSON.stringify(s.txs ?? s.block);
287
+ if (s.txs) { const bad = s.txs.failed ? `<span class="badge bad" title="${s.txs.failed} failed">${s.txs.failed} ✗</span>` : ''; const pend = s.txs.pending ? `<span class="badge" title="${s.txs.pending} pending">${s.txs.pending} ⏳</span>` : ''; bTxs.innerHTML = `Transactions<span class="badge">${s.txs.total}</span>${pend}${bad}`; }
217
288
  const ck = JSON.stringify(s.controls ?? []);
218
- if (ck !== controlsKey) { controlsKey = ck; controls.replaceChildren(...(s.controls ?? []).map((c: any, i: number) => btn(c.label, `control-${i}`, c.title ?? c.method, () => rpc(c.method, c.params ?? [])))); }
289
+ if (ck !== controlsKey) { controlsKey = ck; controls.replaceChildren(...(s.controls ?? []).map((c: any, i: number) => btn(c.label, `control-${i}`, c.title ?? c.method, async () => { const r = await rpc(c.method, c.params ?? []); toast(`${c.label}${r !== undefined && r !== null && typeof r !== 'object' ? ` → ${r}` : ''}`); }))); }
219
290
  bActors.hidden = !s.hasActors; bActors.textContent = `${s.actorsLabel} ${s.actors ? 'on' : 'off'}`; bActors.classList.toggle('on', s.actors);
220
291
  gScenario.hidden = !s.hasActors && !(s.controls?.length);
221
292
  bReject.textContent = s.wallet.rejectNext > 0 ? `Reject next tx · armed (${s.wallet.rejectNext})` : 'Reject next tx'; bReject.classList.toggle('armed', s.wallet.rejectNext > 0);
222
293
  bLatency.textContent = s.wallet.latencyMs ? `Wallet: ${s.wallet.latencyMs / 1000}s delay` : 'Wallet: instant'; bLatency.classList.toggle('on', !!s.wallet.latencyMs);
223
294
  bLag.textContent = s.wallet.receiptLagMs ? `Receipts: ${s.wallet.receiptLagMs / 1000}s late` : 'Receipts: instant'; bLag.classList.toggle('on', !!s.wallet.receiptLagMs);
224
- if (!panel.hidden) { await refreshTxs(); place(); }
295
+ if (!bar.hidden) place();
296
+ if (!panel.hidden && txKey !== lastTxKey) { lastTxKey = txKey; await refreshTxs(); } // refetch the list only when the chain's counts moved; clicks refetch on their own
225
297
  };
226
298
  refresh(); refreshScenarios();
227
299
  let ticks = 0;
228
300
  const timer = setInterval(() => { if (!document.getElementById('terrarium-devbar')) return clearInterval(timer); refresh(); if (++ticks % 10 === 0) refreshScenarios(); }, 500);
229
301
  }
230
302
 
231
- /** Remove the dev bar, its explorer and the show pill; restore the page's bottom padding. Idempotent. */
303
+ /** Remove the dev bar, its explorer, the toasts and the show pill; restore the page's bottom padding. Idempotent. */
232
304
  export function unmountDevBar() {
233
305
  document.getElementById('terrarium-devbar')?.remove();
234
306
  document.getElementById('terrarium-devbar-show')?.remove();
307
+ document.getElementById('terrarium-toast')?.remove();
308
+ document.getElementById('terrarium-devbar-menu')?.remove();
235
309
  document.body?.style.removeProperty('padding-bottom');
236
310
  }
package/src/engine.js CHANGED
@@ -191,7 +191,7 @@ export async function createTerrarium(opts = {}) {
191
191
  // (account) or is zero (slot). No round trip, no re-run. With a fork the truth may be remote: load it (recorded).
192
192
  const local = stateMode !== 'rpc';
193
193
  const revmHost = {
194
- account(address) {
194
+ account(address, wantCode = true) {
195
195
  const key = address.toLowerCase(); let a = mirrorGet('accounts', key);
196
196
  if (a === undefined) {
197
197
  if (local) { mirror[0].accounts.set(key, null); a = null; }
@@ -205,8 +205,9 @@ export async function createTerrarium(opts = {}) {
205
205
  }
206
206
  }
207
207
  if (a === null) return null;
208
- let code = '0x';
209
- if (a.codeHash !== KECCAK_EMPTY) { code = mirrorGet('code', key); if (code === undefined) { misses.push(() => mirrorLoad('code', key)); throw { missing: true }; } }
208
+ if (a.codeHash === KECCAK_EMPTY) return { balance: hex(a.balance), nonce: hex(a.nonce), codeHash: a.codeHash, code: '0x' };
209
+ if (!wantCode) return { balance: hex(a.balance), nonce: hex(a.nonce), codeHash: a.codeHash }; // no `code` key: wasm has it cached by hash, or asks again with wantCode
210
+ const code = mirrorGet('code', key); if (code === undefined) { misses.push(() => mirrorLoad('code', key)); throw { missing: true }; }
210
211
  return { balance: hex(a.balance), nonce: hex(a.nonce), codeHash: a.codeHash, code };
211
212
  },
212
213
  storage(address, slot) { const key = `${address.toLowerCase()}:${slot.toLowerCase()}`; const v = mirrorGet('storage', key); if (v !== undefined) return v; if (local) { mirror[0].storage.set(key, ZERO32); return ZERO32; } misses.push(() => mirrorLoad('storage', key)); throw { missing: true }; },
@@ -221,24 +222,32 @@ export async function createTerrarium(opts = {}) {
221
222
  for (const [slot, value] of c.storage) await sm.putStorage(a, hexToBytes(slot), hexToBytes(value));
222
223
  }
223
224
  }
224
- async function execRevm({ tx, msg, block, flags = {} }) {
225
+ function revmRequest({ tx, msg, block, flags = {} }) {
225
226
  let m = tx ? msgOf(tx) : { ...msg, gasPrice: 0n, priorityFee: 0n, nonce: 0n };
226
227
  if (!tx) flags = { ...flags, skipBalance: true, skipNonce: true, noBaseFee: true, skipEip3607: true };
227
- const req = JSON.stringify({ tx: { from: m.from, to: m.to, value: hex(m.value), data: m.data, gasLimit: hex(m.gasLimit), gasPrice: hex(m.gasPrice), priorityFee: hex(m.priorityFee), nonce: hex(m.nonce), txType: 2 },
228
+ return JSON.stringify({ tx: { from: m.from, to: m.to, value: hex(m.value), data: m.data, gasLimit: hex(m.gasLimit), gasPrice: hex(m.gasPrice), priorityFee: hex(m.priorityFee), nonce: hex(m.nonce), txType: 2 },
228
229
  block: { number: hex(block.header.number), timestamp: hex(block.header.timestamp), gasLimit: hex(block.header.gasLimit), baseFee: hex(block.header.baseFeePerGas ?? 0n) },
229
230
  cfg: { chainId, spec: String(hardfork), skipBalance: !!flags.skipBalance, skipNonce: !!flags.skipNonce, skipBlockGasLimit: true, noBaseFee: !!flags.noBaseFee, skipEip3607: !!flags.skipEip3607, traceSloads: !!flags.traceSloads } });
230
- stats.runs++;
231
+ }
232
+ /** one wasm call (`run` or `estimate`), re-issued after each round of state fetching in fork mode */
233
+ async function revmCall(fn, req) {
231
234
  for (let round = 0; ; round++) {
232
235
  misses.length = 0; stats.rounds++;
233
- let out;
234
236
  const t0 = Date.now();
235
- try { out = JSON.parse(revm.run(revmHost, req)); stats.wasmMs += Date.now() - t0; }
237
+ try { const out = JSON.parse(fn(revmHost, req)); stats.wasmMs += Date.now() - t0; return out; }
236
238
  catch (e) {
237
239
  stats.wasmMs += Date.now() - t0;
238
240
  if (misses.length) { if (round > 100000) throw new Error('revm: state loading did not converge'); for (const load of misses.splice(0)) await load(); continue; }
239
241
  const message = String(e?.message ?? e);
240
242
  throw new Error(message.startsWith('invalid:') ? message : `revm: ${message}`);
241
243
  }
244
+ }
245
+ }
246
+ async function execRevm(args) {
247
+ const req = revmRequest(args);
248
+ stats.runs++;
249
+ {
250
+ const out = await revmCall(revm.run, req);
242
251
  await applyRevmState(out.state);
243
252
  return { success: out.success, error: out.success ? null : out.reason, gasUsed: BigInt(out.gasUsed), gasRefund: BigInt(out.gasRefunded), returnValue: hexToBytes(out.output), logs: out.logs.map((l) => [hexToBytes(l.address), l.topics.map(hexToBytes), hexToBytes(l.data)]), createdAddress: out.created, sloads: out.sloads.map(([address, slot]) => ({ address: address.toLowerCase(), slot })) };
244
253
  }
@@ -262,6 +271,11 @@ export async function createTerrarium(opts = {}) {
262
271
  let recording = true;
263
272
  const tsQueue = []; // block timestamps to reuse during replay (determinism)
264
273
  let persister = null, persistKey = null, persistTimer = null;
274
+ // incremental persistence: blocks are stored in chunks of PERSIST_CHUNK under `<key>:b<i>`; `dirtyFrom` is the lowest block
275
+ // index changed since the last save (Infinity: none), `persistedChunks` how many chunk keys the store holds
276
+ const PERSIST_CHUNK = 64;
277
+ let dirtyFrom = Infinity, persistedChunks = 0;
278
+ const markDirty = (index) => { if (index < dirtyFrom) dirtyFrom = index; };
265
279
  const dealtSlots = new Map(); // token -> balance slot discovered by probing
266
280
  const impersonated = new Set();
267
281
  const exclusive = createLock();
@@ -273,7 +287,7 @@ export async function createTerrarium(opts = {}) {
273
287
 
274
288
  function pushBlock(header, txHashes, receipts) {
275
289
  const b = { ...header, transactions: txHashes, receipts };
276
- blocks.push(b);
290
+ markDirty(blocks.length); blocks.push(b);
277
291
  return b;
278
292
  }
279
293
  const stateRootOf = async () => (stateMode === 'merkle' ? bytesToHex(await sm.getStateRoot()) : (opts.stateRoot ?? ZERO32));
@@ -425,40 +439,43 @@ export async function createTerrarium(opts = {}) {
425
439
 
426
440
  /** Execute a hypothetical tx with a given gas limit on a rollback — real transaction semantics (intrinsic gas,
427
441
  * 63/64 rule for sub-calls, refunds), no state change. */
428
- async function simulateTx(p, gasLimit) {
429
- const from = getAddress(p.from ?? accounts[0].address);
430
- const acct = (await sm.getAccount(createAddressFromString(from))) ?? new Account();
431
- const tx = impersonatedTx({ chainId: BigInt(chainId), nonce: acct.nonce, maxFeePerGas: baseFee * 2n, maxPriorityFeePerGas: 1n, gasLimit, to: p.to ?? undefined, value: p.value ? hexToBigInt(p.value) : 0n, data: p.data ?? p.input ?? '0x' }, from);
432
- return withRollback(() => exec({ tx, block: pendingBlock(), flags: { skipNonce: true, skipBalance: true } }));
433
- }
434
- /** geth/anvil-style estimation: one full run, an optimistic 64/63 probe, then binary search if needed. */
442
+ /** reth-style estimation, run inside the wasm engine in ONE call: a run at the cap (a revert there is the answer),
443
+ * the optimistic (used + refunded + stipend) · 64/63 probe, then a bisection that starts near 3× the gas used and stops
444
+ * within 1.5 %. The runs share a read cache and commit nothing, so the state manager is never touched. */
435
445
  async function estimateGas(p) {
436
446
  const cap = p.gas ? hexToBigInt(p.gas) : gasLimit;
437
- const first = await simulateTx(p, cap);
438
- if (!first.success) throw revertError(first);
439
- const ok = async (g) => { try { const r = await simulateTx(p, g); return r.success; } catch { return false; } };
440
- let lo = first.gasUsed - 1n, hi = cap;
441
- const optimistic = ((first.gasUsed + first.gasRefund) * 64n) / 63n + 1n; // usually exact
442
- if (optimistic < hi && (await ok(optimistic))) hi = optimistic;
443
- while (lo + 1n < hi) { // shrink to the minimum that succeeds
444
- if (hi - lo <= hi / 64n) break; // 1.5 % tolerance like geth
445
- const mid = (lo + hi) / 2n;
446
- if (await ok(mid)) hi = mid; else lo = mid;
447
- }
448
- return hi;
447
+ const from = getAddress(p.from ?? accounts[0].address);
448
+ const acct = (await sm.getAccount(createAddressFromString(from))) ?? new Account();
449
+ const tx = impersonatedTx({ chainId: BigInt(chainId), nonce: acct.nonce, maxFeePerGas: baseFee * 2n, maxPriorityFeePerGas: 1n, gasLimit: cap, to: p.to ?? undefined, value: p.value ? hexToBigInt(p.value) : 0n, data: p.data ?? p.input ?? '0x' }, from);
450
+ const out = await revmCall(revm.estimate, revmRequest({ tx, block: pendingBlock(), flags: { skipNonce: true, skipBalance: true } }));
451
+ stats.runs += out.runs;
452
+ if (!out.success) throw revertError({ error: out.reason, returnValue: hexToBytes(out.output), gasUsed: BigInt(out.gasUsed) });
453
+ return BigInt(out.gas);
449
454
  }
450
455
 
451
456
  /** Transactions newest first, as an explorer lists them: the RPC tx merged with its receipt, a `status` word, the
452
457
  * revert reason and data of a failed one, and the block timestamp. Pending (interval mining) come first. */
458
+ // the counts are polled (the dev bar's status, twice a second): recomputed only when the chain changed
459
+ let txCounts = { key: '', failed: 0 };
460
+ function countTxs() {
461
+ const key = `${txs.size}:${pending.length}:${blocks.length}`;
462
+ if (txCounts.key !== key) { let failed = 0; for (const t of txs.values()) if (t.receipt && t.receipt.status !== '0x1') failed++; txCounts = { key, failed }; }
463
+ return { total: txs.size, pending: pending.length, failed: txCounts.failed };
464
+ }
465
+ function describeTx(t) {
466
+ const mined = !!t.receipt, n = mined ? hexToBigInt(t.receipt.blockNumber) : null;
467
+ const b = mined ? blocks[blocks.length - 1 - Number(latest().number - n)] ?? blocks.find((b) => BigInt(b.number) === n) : null;
468
+ const status = !mined ? 'pending' : t.receipt.status === '0x1' ? 'success' : t.dropped ? 'dropped' : 'reverted';
469
+ return { ...t.rpc, status, receipt: t.receipt, timestamp: b && BigInt(b.number) === n ? hex(b.timestamp) : null, error: t.error ?? null, revertData: t.revertData ?? null };
470
+ }
453
471
  function listTransactions({ limit = 50, before } = {}) {
454
- const all = [...txs.values()].reverse().map((t) => {
455
- const mined = !!t.receipt, n = mined ? hexToBigInt(t.receipt.blockNumber) : null;
456
- const b = mined ? blocks.find((b) => BigInt(b.number) === n) : null;
457
- const status = !mined ? 'pending' : t.receipt.status === '0x1' ? 'success' : t.dropped ? 'dropped' : 'reverted';
458
- return { ...t.rpc, status, receipt: t.receipt, timestamp: b ? hex(b.timestamp) : null, error: t.error ?? null, revertData: t.revertData ?? null };
459
- });
460
- const from = before ? all.findIndex((t) => t.hash === before) + 1 : 0;
461
- return { total: all.length, transactions: all.slice(from, from + Math.max(0, limit)) };
472
+ const all = [...txs.values()], out = []; // only the requested page is built: newest first, after `before` if given
473
+ let started = !before;
474
+ for (let i = all.length - 1; i >= 0 && out.length < Math.max(0, limit); i--) {
475
+ if (!started) { if (all[i].rpc.hash === before) started = true; continue; }
476
+ out.push(describeTx(all[i]));
477
+ }
478
+ return { ...countTxs(), transactions: out };
462
479
  }
463
480
 
464
481
  // ---- RPC formatting ----------------------------------------------------------------------------
@@ -607,7 +624,7 @@ export async function createTerrarium(opts = {}) {
607
624
  const i = snapshots.findIndex((s) => s.id === id); if (i < 0) return false;
608
625
  let target;
609
626
  while (snapshots.length > i) { target = snapshots.pop(); await sm.revert(); }
610
- blocks.length = target.blocksLen; journal.length = target.journalLen; pending.length = 0;
627
+ markDirty(target.blocksLen); blocks.length = target.blocksLen; journal.length = target.journalLen; pending.length = 0;
611
628
  timeOffset = target.timeOffset; nextTimestamp = target.nextTimestamp; baseFee = target.baseFee; // the clock is state too
612
629
  const head = latest().number;
613
630
  for (const [h, t] of txs) if (!t.receipt || hexToBigInt(t.receipt.blockNumber) > head) txs.delete(h);
@@ -744,7 +761,9 @@ export async function createTerrarium(opts = {}) {
744
761
  // ---- persistence: dump the diff, restore it, or replay the journal -------------------------------
745
762
  const serBlock = (b) => ({ ...b, number: hex(b.number), timestamp: hex(b.timestamp), gasLimit: hex(b.gasLimit), gasUsed: hex(b.gasUsed), baseFeePerGas: hex(b.baseFeePerGas) });
746
763
  const deserBlock = (b) => ({ ...b, number: hexToBigInt(b.number), timestamp: hexToBigInt(b.timestamp), gasLimit: hexToBigInt(b.gasLimit), gasUsed: hexToBigInt(b.gasUsed), baseFeePerGas: hexToBigInt(b.baseFeePerGas) });
747
- async function dumpState() {
764
+ async function dumpState() { const core = await dumpCore(); return { ...core, chain: { ...core.chain, blocks: blocks.map(serBlock) } }; }
765
+ /** everything but the blocks (the part the incremental save rewrites every time) */
766
+ async function dumpCore() {
748
767
  const keepFrom = latest().number - BigInt(opts.persist?.maxTxBlocks ?? 2000);
749
768
  const accountsOut = {};
750
769
  for (const a of touched.accounts) { const acct = await sm.getAccount(createAddressFromString(a)); accountsOut[a] = acct ? { nonce: hex(acct.nonce), balance: hex(acct.balance), codeHash: bytesToHex(acct.codeHash) } : null; }
@@ -753,7 +772,7 @@ export async function createTerrarium(opts = {}) {
753
772
  const remote = sm.remote ? { accounts: Object.fromEntries([...sm.remote.accounts].map(([a, acct]) => [a, acct ? { nonce: hex(acct.nonce), balance: hex(acct.balance), codeHash: bytesToHex(acct.codeHash) } : null])), code: Object.fromEntries([...sm.remote.code].map(([a, c]) => [a, bytesToHex(c)])), storage: Object.fromEntries([...sm.remote.storage].map(([k, v]) => [k, bytesToHex(v)])) } : undefined;
754
773
  return { version: 1, chainId, savedAt: Date.now(), state: { accounts: accountsOut, code: codeOut, storage: storageOut }, remote,
755
774
  // tx bodies: only the most recent blocks' worth (like a pruned node), and without their logs (rebuilt from block logs on load)
756
- chain: { blocks: blocks.map(serBlock), txs: Object.fromEntries([...txs].filter(([, t]) => !t.receipt || hexToBigInt(t.receipt.blockNumber) >= keepFrom).map(([h, t]) => [h, { rpc: t.rpc, receipt: t.receipt ? { ...t.receipt, logs: undefined } : null, error: t.error, revertData: t.revertData, dropped: t.dropped }])), timeOffset: hex(timeOffset), baseFee: hex(baseFee), mining, impersonated: [...impersonated], dealtSlots: Object.fromEntries(dealtSlots) },
775
+ chain: { txs: Object.fromEntries([...txs].filter(([, t]) => !t.receipt || hexToBigInt(t.receipt.blockNumber) >= keepFrom).map(([h, t]) => [h, { rpc: t.rpc, receipt: t.receipt ? { ...t.receipt, logs: undefined } : null, error: t.error, revertData: t.revertData, dropped: t.dropped }])), timeOffset: hex(timeOffset), baseFee: hex(baseFee), mining, impersonated: [...impersonated], dealtSlots: Object.fromEntries(dealtSlots) },
757
776
  journal: { entries: journal, timestamps: blocks.slice(1).map((b) => hex(b.timestamp)) } };
758
777
  }
759
778
  async function seedState({ accounts: accs = {}, code = {}, storage = {} }) {
@@ -767,7 +786,7 @@ export async function createTerrarium(opts = {}) {
767
786
  for (const [k, v] of Object.entries(dump.remote.storage)) { const [a, slot] = k.split('_'); await sm.putStorage(createAddressFromString(a), hexToBytes(slot), hexToBytes(v)); }
768
787
  }
769
788
  await seedState(dump.state);
770
- blocks.length = 0; for (const b of dump.chain.blocks) blocks.push(deserBlock(b));
789
+ markDirty(0); blocks.length = 0; for (const b of dump.chain.blocks) blocks.push(deserBlock(b));
771
790
  txs.clear(); for (const [h, t] of Object.entries(dump.chain.txs)) { if (t.receipt) t.receipt.logs = []; txs.set(h, t); }
772
791
  for (const b of blocks) for (const l of b.logs) { const t = txs.get(l.transactionHash); if (t?.receipt) t.receipt.logs.push(l); }
773
792
  timeOffset = hexToBigInt(dump.chain.timeOffset); baseFee = hexToBigInt(dump.chain.baseFee); mining = dump.chain.mining;
@@ -780,16 +799,46 @@ export async function createTerrarium(opts = {}) {
780
799
  recording = false; tsQueue.push(...(j.timestamps ?? []).map(hexToBigInt));
781
800
  try { for (const e of j.entries) await dispatch(e); } finally { recording = true; tsQueue.length = 0; }
782
801
  }
802
+ /** write what changed: the dirty block chunks, chunks past the head removed (a revert), then the core with the block count.
803
+ * Chunks first, core last, so a crash in between leaves a core that never points past what the store holds. */
804
+ async function persistNow() {
805
+ if (!persister) return;
806
+ const key = persistKey, store = persister;
807
+ const nChunks = Math.ceil(blocks.length / PERSIST_CHUNK);
808
+ const firstDirty = dirtyFrom === Infinity ? nChunks : Math.floor(Math.min(dirtyFrom, blocks.length) / PERSIST_CHUNK);
809
+ for (let i = firstDirty; i < nChunks; i++) await store.setItem(`${key}:b${i}`, JSON.stringify(blocks.slice(i * PERSIST_CHUNK, (i + 1) * PERSIST_CHUNK).map(serBlock)));
810
+ for (let i = nChunks; i < persistedChunks; i++) await store.removeItem(`${key}:b${i}`);
811
+ const core = await dumpCore();
812
+ await store.setItem(key, JSON.stringify({ ...core, chain: { ...core.chain, blockCount: blocks.length, chunk: PERSIST_CHUNK } }));
813
+ persistedChunks = nChunks; dirtyFrom = Infinity;
814
+ }
815
+ /** the persisted chain: a core + block chunks (or a whole dump, the pre-0.7 format) → a full dump, or null */
816
+ async function readPersisted(store, key) {
817
+ const raw = await store.getItem(key); if (!raw) return null;
818
+ const saved = typeof raw === 'string' ? JSON.parse(raw) : raw;
819
+ if (saved.chain?.blocks) return saved; // legacy: everything in one value
820
+ const all = [], n = Math.ceil((saved.chain?.blockCount ?? 0) / (saved.chain?.chunk ?? PERSIST_CHUNK));
821
+ for (let i = 0; i < n; i++) { const c = await store.getItem(`${key}:b${i}`); if (!c) break; all.push(...(typeof c === 'string' ? JSON.parse(c) : c)); }
822
+ return { ...saved, chain: { ...saved.chain, blocks: all.slice(0, saved.chain.blockCount) } };
823
+ }
824
+ /** remove everything this engine persisted under its key (the core and every block chunk) */
825
+ async function clearPersisted() {
826
+ if (!persister) return;
827
+ const raw = await persister.getItem(persistKey); const saved = raw ? (typeof raw === 'string' ? JSON.parse(raw) : raw) : null;
828
+ const n = Math.max(persistedChunks, Math.ceil((saved?.chain?.blockCount ?? 0) / (saved?.chain?.chunk ?? PERSIST_CHUNK)));
829
+ for (let i = 0; i < n; i++) await persister.removeItem(`${persistKey}:b${i}`);
830
+ await persister.removeItem(persistKey); persistedChunks = 0; dirtyFrom = 0;
831
+ }
783
832
  function schedulePersist() {
784
833
  if (!persister) return;
785
834
  clearTimeout(persistTimer);
786
- persistTimer = setTimeout(() => exclusive(async () => persister.setItem(persistKey, JSON.stringify(await dumpState()))).catch((e) => console.warn('terrarium persist failed', e)), opts.persist?.debounceMs ?? 50);
835
+ persistTimer = setTimeout(() => exclusive(persistNow).catch((e) => console.warn('terrarium persist failed', e)), opts.persist?.debounceMs ?? 50);
787
836
  }
788
837
  let restoredFromPersistence = false;
789
838
  if (opts.persist) {
790
839
  persister = opts.persist.storage; persistKey = opts.persist.key ?? `terrarium:${chainId}`;
791
- const saved = await persister.getItem(persistKey);
792
- if (saved) { await loadState(typeof saved === 'string' ? JSON.parse(saved) : saved); restoredFromPersistence = true; }
840
+ const saved = await readPersisted(persister, persistKey);
841
+ if (saved) { await loadState(saved); restoredFromPersistence = true; if (!saved.chain.chunk) { dirtyFrom = 0; persistedChunks = 0; } else { dirtyFrom = Infinity; persistedChunks = Math.ceil(blocks.length / PERSIST_CHUNK); } } // a legacy dump is rewritten in chunks on the next save
793
842
  }
794
843
  if (!restoredFromPersistence && opts.restore) await loadState(opts.restore); // a recorded fixture as the baseline
795
844
 
@@ -808,9 +857,11 @@ export async function createTerrarium(opts = {}) {
808
857
  slotFromLayout,
809
858
  /** Persistence: dump the diff (+ fork fixture), restore, or replay the journal onto new bytecode. */
810
859
  dumpState: () => exclusive(dumpState), loadState: (d) => exclusive(() => loadState(d)), replayJournal: (j) => exclusive(() => replayJournal(j)),
811
- get journal() { return journal.slice(); }, flush: () => { clearTimeout(persistTimer); return persister ? exclusive(async () => persister.setItem(persistKey, JSON.stringify(await dumpState()))) : Promise.resolve(); },
860
+ get journal() { return journal.slice(); }, flush: () => { clearTimeout(persistTimer); return persister ? exclusive(persistNow) : Promise.resolve(); },
861
+ /** remove this engine's persisted chain (core + block chunks) from the store */
862
+ clearPersisted: () => exclusive(clearPersisted),
812
863
  get blockNumber() { return latest().number; },
813
- /** transactions newest first ({ total, transactions }): tx + receipt + status word + revert reason/data + block timestamp */
864
+ /** transactions newest first ({ total, pending, failed, transactions }): tx + receipt + status word + revert reason/data + block timestamp */
814
865
  transactions: (o) => listTransactions(o),
815
866
  /** React to on-chain events with scripted actors (keepers, oracles, other users, bridges...). */
816
867
  onLog(filter, handler) { const l = { filter, handler }; logListeners.push(l); return () => logListeners.splice(logListeners.indexOf(l), 1); },
@@ -108,7 +108,7 @@ export async function runScenario(input: ScenarioInput, opts: { storage?: Storag
108
108
 
109
109
  // ---- generic controls, reachable through the provider like any RPC method -----------------------------------
110
110
  sim.addMethod('terrarium_actors', async (on?: boolean) => { await actors.toggle(on ?? !actors.enabled); return actors.enabled; });
111
- sim.addMethod('terrarium_status', async () => ({ scenario: scenarioName, chainId, engine: sim.engine, block: toHex(sim.blockNumber), accounts: ctx.accounts, actors: actors.enabled, actorsLabel: config.actorsLabel ?? 'Actors', hasActors: toggled.length > 0, wallet: { ...sim.wallet }, controls: config.controls ?? [], restoredFromPersistence: sim.restoredFromPersistence, localBlocks: Number(sim.blockNumber) - (config.fork ? config.fork.blockNumber + 1 : 0), http: { routes: httpRoutes.length, hits: httpHits }, fork: config.fork ? { blockNumber: config.fork.blockNumber, offline: !!config.fork.offline, misses: sim.offlineMisses.length } : null, ...(await config.status?.(ctx)) }));
111
+ sim.addMethod('terrarium_status', async () => ({ scenario: scenarioName, chainId, engine: sim.engine, block: toHex(sim.blockNumber), now: toHex(sim.now()), txs: (({ total, pending, failed }) => ({ total, pending, failed }))(sim.transactions({ limit: 0 })), accounts: ctx.accounts, actors: actors.enabled, actorsLabel: config.actorsLabel ?? 'Actors', hasActors: toggled.length > 0, wallet: { ...sim.wallet }, controls: config.controls ?? [], restoredFromPersistence: sim.restoredFromPersistence, localBlocks: Number(sim.blockNumber) - (config.fork ? config.fork.blockNumber + 1 : 0), http: { routes: httpRoutes.length, hits: httpHits }, fork: config.fork ? { blockNumber: config.fork.blockNumber, offline: !!config.fork.offline, misses: sim.offlineMisses.length } : null, ...(await config.status?.(ctx)) }));
112
112
  // ---- the transaction explorer: the engine's list, decoded (address's own ABI, then `abis`, then the known set) and labelled
113
113
  const configAbi = (config.abis ?? []).flat() as Abi;
114
114
  const abisFor = (address: string | null): Abi[] => [registry.get((address ?? '').toLowerCase())?.abi, configAbi, KNOWN_ABI].filter((a): a is Abi => !!a && a.length > 0);
@@ -127,8 +127,8 @@ export async function runScenario(input: ScenarioInput, opts: { storage?: Storag
127
127
  // events: every candidate with the log's topic0 is tried (ERC-20 and ERC-721 Transfer share it and differ in indexed params)
128
128
  const decodeLog = (l: { address: string; topics: Hex[]; data: Hex }) => first(abisFor(l.address), (abi: Abi) => first(abi.filter((i) => i.type === 'event' && toEventSelector(i as any) === l.topics[0]), (item: any) => { const d: any = decodeEventLog({ abi: [item], data: l.data, topics: l.topics as [Hex, ...Hex[]] }); return { name: d.eventName as string, args: plain(d.args ?? {}) }; }));
129
129
  sim.addMethod('terrarium_transactions', (opts?: { limit?: number; before?: string }) => {
130
- const { total, transactions } = sim.transactions(opts ?? {});
131
- return { total, labels: labelsOf(), transactions: transactions.map((t: any) => {
130
+ const { total, pending, failed, transactions } = sim.transactions(opts ?? {});
131
+ return { total, pending, failed, labels: labelsOf(), transactions: transactions.map((t: any) => {
132
132
  const hasData = t.input && t.input.length >= 10;
133
133
  const method = !t.to ? { name: 'create', args: [] } : hasData ? decodeCall(t.to, t.input) ?? { name: null, selector: t.input.slice(0, 10) } : null;
134
134
  const revert = t.status === 'reverted' && t.revertData && t.revertData !== '0x' ? decodeRevert(t.to, t.revertData) : null;
@@ -137,7 +137,7 @@ export async function runScenario(input: ScenarioInput, opts: { storage?: Storag
137
137
  }) };
138
138
  });
139
139
  // reset wipes this scenario's chain (its key and its actors flag); other scenarios and the selection stay
140
- sim.addMethod('terrarium_reset', async () => { await actors.toggle(false); sim.stop(); if (storage && key) { await storage.removeItem(key); await storage.removeItem(actorsKey); } return true; });
140
+ sim.addMethod('terrarium_reset', async () => { await actors.toggle(false); if (storage && key) { await sim.clearPersisted(); await storage.removeItem(actorsKey); } sim.stop(); return true; });
141
141
  // several scenarios: the list for the selector, and the switch (stored, then the page reloads and boots the chosen one)
142
142
  sim.addMethod('terrarium_scenarios', () => ({ active: scenarioName, scenarios: list.map((s, i) => ({ name: names[i], description: s.description ?? null, persist: s.persist === false ? null : (s.persist ?? (list.length > 1 ? slug(names[i]) : 'default')) })) }));
143
143
  sim.addMethod('terrarium_selectScenario', async (name: string) => {