@terrariumlabs/core 0.3.0 → 0.5.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/bin/terrarium.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // terrarium — the CLI.
3
3
  //
4
- // terrarium build [--scenario terrarium.scenario.ts] [--out dist-terrarium]
4
+ // terrarium build [--scenario terrarium.scenario.ts] [--out dist-terrarium] [--devbar hidden|off]
5
5
  // One injectable classic script (dist-terrarium/terrarium.js): chain Worker + wallet + dev bar. Inject it into
6
6
  // any page (Playwright addInitScript, a <script> tag, a bookmarklet) — your dapp built without the Terrarium,
7
7
  // a Storybook, someone else's dapp.
@@ -15,6 +15,12 @@
15
15
  // account (balance, nonce, code) and storage slot, runs your script against the fork (every account, code blob
16
16
  // and slot the EVM touches is recorded), rolls the script's changes back (unless --keep) and dumps. The fixture
17
17
  // is what a scenario's `fork: { blockNumber, offline: true }, restore: fixture.dump` consumes.
18
+ //
19
+ // terrarium import-anvil [name=0xaddress]... --rpc <url> [--broadcast run-latest.json] [--artifacts out/] [--out fixture.json] [--skip 0xaddress]...
20
+ // Everything a running Anvil holds (anvil_dumpState: every account's code, storage, nonce and balance) as a fixture
21
+ // for ctx.install(): deploy your protocol with its own tooling (forge script, hardhat deploy) against Anvil and
22
+ // boot the Terrarium from the result, byte for byte. --skip leaves accounts out (Anvil's funded test accounts,
23
+ // which the Terrarium funds itself, are skipped by default).
18
24
  import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
19
25
  import { dirname, join, relative, resolve, sep } from 'node:path';
20
26
  import { pathToFileURL } from 'node:url';
@@ -28,9 +34,12 @@ for (let i = 0; i < rest.length; i++) {
28
34
  }
29
35
  const define = { 'process.env.DEBUG': 'undefined', 'process.env.TERRARIUM_DEBUG': 'undefined' };
30
36
  const usage = `usage:
31
- terrarium build [--scenario terrarium.scenario.ts] [--out dist-terrarium]
37
+ terrarium build [--scenario terrarium.scenario.ts] [--out dist-terrarium] [--devbar hidden|off]
32
38
  terrarium fetch-code <name=0xaddress>... --rpc <url> [--block N] [--chain ID] [--out fixture.json]
33
- terrarium record [name=0xaddress]... --rpc <url> [--block N] [--chain ID] [--storage name:slot,slot] [--script warm.mjs] [--keep] [--out fixture.json]`;
39
+ terrarium record [name=0xaddress]... --rpc <url> [--block N] [--chain ID] [--storage name:slot,slot] [--script warm.mjs] [--keep] [--out fixture.json]
40
+ terrarium import-anvil [name=0xaddress]... --rpc <url> [--broadcast run-latest.json] [--artifacts out/] [--out fixture.json] [--skip 0xaddress]...
41
+ --broadcast: a Foundry broadcast (contract names + addresses), --artifacts: the out/ (or artifacts/) dir with their ABIs;
42
+ both land in the fixture as names/abis, so the dev bar's explorer shows the deployment decoded with no scenario code`;
34
43
  const fail = (msg) => { console.error(msg); process.exit(1); };
35
44
  const hex = (n) => '0x' + BigInt(n).toString(16);
36
45
  /** a raw JSON-RPC call to --rpc */
@@ -53,7 +62,7 @@ if (cmd === 'build') {
53
62
  const scenario = '/' + relative(root, resolve(root, args.scenario ?? 'terrarium.scenario.ts')).split(sep).join('/');
54
63
  const dir = resolve(root, '.terrarium'); mkdirSync(dir, { recursive: true });
55
64
  writeFileSync(join(dir, 'worker.ts'), `import scenario from '${scenario}';\nimport { runScenario } from '@terrariumlabs/core/worker';\nrunScenario(scenario);\n`);
56
- writeFileSync(join(dir, 'inject-bundle.ts'), `import { startTerrarium } from '@terrariumlabs/core/inject';\ndeclare const __TERRARIUM_WORKER_SRC__: string;\nstartTerrarium(new Worker(URL.createObjectURL(new Blob([__TERRARIUM_WORKER_SRC__], { type: 'text/javascript' })), { type: 'module' }));\n`);
65
+ writeFileSync(join(dir, 'inject-bundle.ts'), `import { startTerrarium } from '@terrariumlabs/core/inject';\ndeclare const __TERRARIUM_WORKER_SRC__: string;\nstartTerrarium(new Worker(URL.createObjectURL(new Blob([__TERRARIUM_WORKER_SRC__], { type: 'text/javascript' })), { type: 'module' })${args.devbar === 'hidden' ? ", { devBar: 'hidden' }" : args.devbar === 'off' ? ', { devBar: false }' : ''});\n`);
57
66
  await build({ root, configFile: false, logLevel: 'warn', define, build: { outDir: out, emptyOutDir: true, target: 'es2022', minify: true, lib: { entry: '.terrarium/worker.ts', formats: ['es'], fileName: () => 'terrarium.worker.js' }, rollupOptions: { output: { codeSplitting: false } } } });
58
67
  const outDir = resolve(root, out);
59
68
  const workerSrc = readFileSync(join(outDir, 'terrarium.worker.js'), 'utf8');
@@ -74,6 +83,52 @@ if (cmd === 'build') {
74
83
  const out = args.out ?? 'fixture.json';
75
84
  writeFileSync(out, JSON.stringify(fixture, null, 2));
76
85
  console.log(`wrote ${out} (chain ${chainId}, block ${blockNumber})`);
86
+ } else if (cmd === 'import-anvil') {
87
+ if (!args.rpc) fail(usage);
88
+ const { gunzipSync } = await import('node:zlib');
89
+ const { TEST_KEYS } = await import('../src/engine.js');
90
+ const { privateKeyToAccount } = await import('viem/accounts');
91
+ const chainId = await resolveChain();
92
+ const blockNumber = Number(await remote('eth_blockNumber'));
93
+ const raw = await remote('anvil_dumpState');
94
+ const bytes = Buffer.from(raw.slice(2), 'hex');
95
+ const dump = JSON.parse((bytes[0] === 0x1f && bytes[1] === 0x8b ? gunzipSync(bytes) : bytes).toString('utf8'));
96
+ // Anvil's ten test accounts are the Terrarium's own (funded at genesis): only their nonces matter, so a deployer's
97
+ // next transaction does not collide with what it already deployed
98
+ const testAccounts = new Set(TEST_KEYS.map((k) => privateKeyToAccount(k).address.toLowerCase()));
99
+ const skip = new Set([].concat(args.skip ?? []).map((a) => String(a).toLowerCase()));
100
+ const accounts = {}; let slots = 0, contracts = 0;
101
+ for (const [address, acct] of Object.entries(dump.accounts ?? {})) {
102
+ const a = address.toLowerCase(); if (skip.has(a) || !acct) continue;
103
+ const code = acct.code && acct.code !== '0x' ? acct.code : undefined;
104
+ const storage = acct.storage && Object.keys(acct.storage).length ? acct.storage : undefined;
105
+ if (testAccounts.has(a)) { if (Number(acct.nonce)) accounts[a] = { nonce: Number(acct.nonce) }; continue; }
106
+ accounts[a] = { nonce: Number(acct.nonce), balance: acct.balance, code, storage };
107
+ if (code) contracts++; slots += Object.keys(storage ?? {}).length;
108
+ }
109
+ // names: name=0xaddress positionals, and a Foundry broadcast (forge script --broadcast writes broadcast/<Script>/<chain>/run-latest.json)
110
+ const names = {};
111
+ for (const p of positional) { const [name, address] = p.split('='); if (name && address?.startsWith('0x')) names[address.toLowerCase()] = name; }
112
+ if (args.broadcast) {
113
+ const b = JSON.parse(readFileSync(resolve(process.cwd(), args.broadcast), 'utf8'));
114
+ for (const tx of b.transactions ?? []) {
115
+ if (tx.contractName && tx.contractAddress) names[tx.contractAddress.toLowerCase()] ??= tx.contractName;
116
+ for (const c of tx.additionalContracts ?? []) if (c.address) names[c.address.toLowerCase()] ??= c.contractName ?? (tx.contractName ? `${tx.contractName} (created)` : 'contract');
117
+ }
118
+ }
119
+ // ABIs: for each named contract, <name>.json somewhere under --artifacts (Foundry out/<File>.sol/<Name>.json, Hardhat artifacts/…)
120
+ const abis = {};
121
+ if (args.artifacts) {
122
+ const { readdirSync } = await import('node:fs');
123
+ const files = new Map();
124
+ const walk = (dir) => { for (const e of readdirSync(dir, { withFileTypes: true })) { const p = join(dir, e.name); if (e.isDirectory()) walk(p); else if (e.name.endsWith('.json') && !e.name.endsWith('.dbg.json')) files.set(e.name.slice(0, -5), p); } };
125
+ walk(resolve(process.cwd(), args.artifacts));
126
+ for (const [address, name] of Object.entries(names)) { const p = files.get(name); if (!p) continue; const abi = JSON.parse(readFileSync(p, 'utf8')).abi; if (Array.isArray(abi)) abis[address] = abi; }
127
+ }
128
+ const fixture = { source: `Anvil state imported with \`terrarium import-anvil\` via ${args.rpc}`, chainId, blockNumber, importedAt: new Date().toISOString(), accounts, ...(Object.keys(names).length ? { names } : {}), ...(Object.keys(abis).length ? { abis } : {}) };
129
+ const out = args.out ?? 'fixture.json';
130
+ writeFileSync(out, JSON.stringify(fixture));
131
+ console.log(`wrote ${out}: ${contracts} contracts, ${Object.keys(accounts).length} accounts, ${slots} storage slots (chain ${chainId}, block ${blockNumber})${Object.keys(names).length ? `, ${Object.keys(names).length} named, ${Object.keys(abis).length} with an ABI` : ''}`);
77
132
  } else if (cmd === 'record') {
78
133
  if (!args.rpc) fail(usage);
79
134
  const [{ createTerrarium }, viem] = await Promise.all([import('../src/engine.js'), import('viem')]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terrariumlabs/core",
3
- "version": "0.3.0",
3
+ "version": "0.5.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",
@@ -33,7 +33,7 @@
33
33
  "access": "public"
34
34
  },
35
35
  "bin": {
36
- "terrarium": "./bin/terrarium.mjs"
36
+ "terrarium": "bin/terrarium.mjs"
37
37
  },
38
38
  "exports": {
39
39
  ".": "./src/engine.js",
@@ -44,6 +44,7 @@
44
44
  "./devbar": "./src/devbar.ts",
45
45
  "./bridge": "./src/bridge.ts",
46
46
  "./http": "./src/http.ts",
47
+ "./transport": "./src/transport.ts",
47
48
  "./vite": "./src/vite-plugin.js",
48
49
  "./fixtures/*": "./fixtures/*"
49
50
  },
@@ -60,7 +61,7 @@
60
61
  "@ethereumjs/util": "^10.1.3",
61
62
  "@ethereumjs/mpt": "^10.1.3",
62
63
  "@ethereumjs/rlp": "^10.0.0",
63
- "@terrariumlabs/evm": "^0.3.0"
64
+ "@terrariumlabs/evm": "^0.5.0"
64
65
  },
65
66
  "peerDependencies": {
66
67
  "viem": "^2.0.0",
package/src/devbar.ts CHANGED
@@ -1,21 +1,78 @@
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.
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.
3
6
  type Provider = { request(a: { method: string; params?: unknown[] }): Promise<any> };
4
7
 
8
+ const HIDDEN_KEY = 'terrarium:devbar-hidden';
5
9
  const CSS = `
6
- #terrarium-devbar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 2147483000; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 10px 24px; background: #14231b; color: #dfe9e3; font: 13px/1.4 ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; font-variant-numeric: tabular-nums; }
7
- #terrarium-devbar .tag { background: #e8c547; color: #14231b; font-weight: 700; padding: 2px 8px; border-radius: 6px; }
8
- #terrarium-devbar .muted { color: rgba(223, 233, 227, 0.7); }
9
- #terrarium-devbar .muted b { color: #fff; font-weight: 600; }
10
- #terrarium-devbar .spacer { flex: 1; }
11
- #terrarium-devbar button { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.14); color: #fff; padding: 6px 10px; border-radius: 8px; font: inherit; cursor: pointer; }
12
- #terrarium-devbar button:hover { background: rgba(255,255,255,0.16); }
13
- #terrarium-devbar button.on { background: #1f6f5c; border-color: #1f6f5c; }
10
+ #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 .brand { display: flex; align-items: center; gap: 8px; }
13
+ #terrarium-devbar .tag { background: #e8c547; color: #14231b; font-weight: 700; padding: 2px 8px; border-radius: 6px; letter-spacing: 0.01em; }
14
+ #terrarium-devbar .name { color: #fff; font-weight: 600; }
15
+ #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; }
17
+ #terrarium-devbar .status b { color: #fff; font-weight: 600; }
18
+ #terrarium-devbar .status .warn { color: #e8c547; }
19
+ #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); }
21
+ #terrarium-devbar .group[hidden] { display: none; }
22
+ #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
+ #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; }
25
+ #terrarium-devbar button:hover { background: rgba(255,255,255,0.15); }
26
+ #terrarium-devbar button.on { background: #1f6f5c; border-color: #2b8a73; }
14
27
  #terrarium-devbar button.armed { background: #7a3b2a; border-color: #b3452c; }
15
28
  #terrarium-devbar button.danger { border-color: rgba(255,140,110,0.4); color: #ffb5a0; }
29
+ #terrarium-devbar button.quiet { background: transparent; border-color: transparent; color: rgba(223,233,227,0.6); }
30
+ #terrarium-devbar button.quiet:hover { color: #fff; background: rgba(255,255,255,0.08); }
31
+ #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); }
32
+ #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); }
34
+ #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; }
36
+ #terrarium-explorer table { width: 100%; border-collapse: collapse; }
37
+ #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
+ #terrarium-explorer th:first-child, #terrarium-explorer td:first-child { padding-left: 16px; }
39
+ #terrarium-explorer td { padding: 6px 12px; border-bottom: 1px solid rgba(255,255,255,0.05); white-space: nowrap; vertical-align: top; }
40
+ #terrarium-explorer tr.tx { cursor: pointer; }
41
+ #terrarium-explorer tr.tx:hover td { background: rgba(255,255,255,0.04); }
42
+ #terrarium-explorer tr.tx.open td { background: rgba(255,255,255,0.06); border-bottom-color: transparent; }
43
+ #terrarium-explorer .ok { color: #7fd3a8; } #terrarium-explorer .bad { color: #ff9c80; } #terrarium-explorer .wait { color: #e8c547; }
44
+ #terrarium-explorer .hash, #terrarium-explorer .addr { color: #9ecbff; }
45
+ #terrarium-explorer .name { color: #e8c547; }
46
+ #terrarium-explorer .dim { color: rgba(223,233,227,0.55); }
47
+ #terrarium-explorer .detail td { padding: 10px 16px 14px 40px; white-space: normal; background: rgba(0,0,0,0.25); }
48
+ #terrarium-explorer dl { display: grid; grid-template-columns: max-content 1fr; gap: 3px 16px; margin: 0; }
49
+ #terrarium-explorer dt { color: rgba(223,233,227,0.6); } #terrarium-explorer dd { margin: 0; word-break: break-all; }
50
+ #terrarium-explorer .events { margin: 10px 0 0; padding: 0; list-style: none; }
51
+ #terrarium-explorer .events li { padding: 6px 0 6px 10px; border-left: 2px solid rgba(232,197,71,0.5); margin-bottom: 4px; word-break: break-all; }
52
+ #terrarium-explorer .args { color: #dfe9e3; }
53
+ #terrarium-explorer .empty { padding: 24px; text-align: center; color: rgba(223,233,227,0.55); font: 13px ui-sans-serif, system-ui, sans-serif; }
16
54
  `;
17
55
 
18
- export function mountDevBar(provider: Provider) {
56
+ const esc = (s: unknown) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]!);
57
+ const short = (h: string) => (h && h.length > 14 ? `${h.slice(0, 8)}…${h.slice(-6)}` : h);
58
+ const num = (hex: string | null | undefined) => (hex ? BigInt(hex).toString() : '');
59
+ /** wei (hex) as ETH with up to 6 decimals, trailing zeros trimmed */
60
+ export const formatEth = (hex: string | null | undefined) => {
61
+ if (!hex) return '0';
62
+ const wei = BigInt(hex), whole = wei / 10n ** 18n, frac = (wei % 10n ** 18n).toString().padStart(18, '0').slice(0, 6).replace(/0+$/, '');
63
+ return frac ? `${whole}.${frac}` : whole.toString();
64
+ };
65
+ /** wei (hex) as gwei with up to 3 decimals */
66
+ 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
+ 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
+ const time = (hex: string | null) => (hex ? new Date(Number(BigInt(hex)) * 1000).toLocaleTimeString() : '');
69
+
70
+ export interface DevBarOptions {
71
+ /** start collapsed to the leaf (default false). A Hide/Show click is remembered in localStorage and overrides this */
72
+ hidden?: boolean;
73
+ }
74
+
75
+ export function mountDevBar(provider: Provider, opts: DevBarOptions = {}) {
19
76
  if (document.getElementById('terrarium-devbar')) return;
20
77
  const rpc = (method: string, params: unknown[] = []) => provider.request({ method, params });
21
78
  const bar = document.createElement('footer');
@@ -23,47 +80,157 @@ export function mountDevBar(provider: Provider) {
23
80
  const style = document.createElement('style'); style.textContent = CSS;
24
81
  bar.append(style);
25
82
  const el = (html: string) => { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild as HTMLElement; };
26
- const btn = (label: string, testid: string, title: string, onClick: () => Promise<unknown> | void) => { const b = el(`<button data-testid="${testid}" title="${title}">${label}</button>`); b.onclick = () => Promise.resolve(onClick()).catch((e) => console.warn('[terrarium]', e)); return b; };
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; };
27
85
 
28
- const info = el(`<span class="muted">simulated chain <span data-f="chain">…</span> · block <b data-testid="block" data-f="block">…</b> · <span data-f="engine">…</span> · state persists in IndexedDB</span>`);
86
+ // ---- brand + scenario selector + status ---------------------------------------------------------------------------
87
+ 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;
89
+ brand.append(scenarioSelect);
90
+ 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>`);
93
+
94
+ // ---- chain ------------------------------------------------------------------------------------------------------------
29
95
  let mining: 'auto' | 'interval' = 'auto', snap: string | null = null;
30
96
  const bMining = btn('Blocks: instant', 'mining', 'Auto: a block per transaction. Interval: a block every 3s, so you can watch pending states', async () => {
31
97
  mining = mining === 'auto' ? 'interval' : 'auto';
32
98
  await (mining === 'auto' ? rpc('evm_setAutomine', [true]) : rpc('evm_setIntervalMining', [3000]));
33
- bMining.textContent = mining === 'auto' ? 'Blocks: instant' : 'Blocks: every 3s';
99
+ bMining.textContent = mining === 'auto' ? 'Blocks: instant' : 'Blocks: every 3s'; bMining.classList.toggle('on', mining === 'interval');
34
100
  });
35
101
  const bSnap = btn('Snapshot', 'snapshot', 'Snapshot the chain; revert brings blocks, receipts, journal and the UI history back', async () => {
36
- if (snap) { await rpc('evm_revert', [snap]); snap = null; bSnap.textContent = 'Snapshot'; }
37
- else { snap = await rpc('evm_snapshot'); bSnap.textContent = 'Revert to snapshot'; }
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'); }
38
104
  });
39
- const bActors = btn('Actors off', 'actors', 'Scripted actors: other users, keepers, arbitrageurs trading on their own', () => rpc('terrarium_actors'));
105
+ 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);
109
+
110
+ // ---- wallet -----------------------------------------------------------------------------------------------------------
40
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 }]));
41
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 }]); });
42
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 }]); });
43
- const bReset = btn('Reset pond', 'reset', 'Wipe the chain and redeploy everything', async () => { await rpc('terrarium_reset'); location.reload(); });
44
- bReset.classList.add('danger');
114
+ const gWallet = group('wallet', 'group-wallet', bReject, bLatency, bLag);
45
115
 
116
+ // ---- 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'));
46
118
  const controls = el('<span class="controls" style="display:contents"></span>'); let controlsKey = '';
47
- bar.append(el('<span class="tag">Terrarium</span>'), info, el('<span class="spacer"></span>'), controls,
48
- btn('Mine a block', 'mine', 'Mine one empty block', () => rpc('evm_mine')),
49
- btn('+1 hour', 'plus-hour', 'Move the chain clock forward one hour', async () => { await rpc('evm_increaseTime', [3600]); await rpc('evm_mine'); }),
50
- bMining, bSnap, bActors, bReject, bLatency, bLag, bReset);
51
- document.body.append(bar);
52
- document.body.style.paddingBottom = '64px';
119
+ const gScenario = group('scenario', 'group-scenario', bActors, controls);
53
120
 
121
+ // ---- the transaction explorer -----------------------------------------------------------------------------------------
122
+ const panel = el(`<section id="terrarium-explorer" data-testid="explorer" hidden></section>`);
123
+ 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`; };
130
+ 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
+ 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>';
132
+ 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]);
134
+ if (key === lastRender) return; lastRender = key;
135
+ 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());
137
+ const rows = shown.map((t) => {
138
+ 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
+ const isOpen = open.has(t.hash);
140
+ 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>
142
+ <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
+ <dt>block</dt><dd>${t.receipt ? `${num(t.receipt.blockNumber)} · ${esc(time(t.timestamp))} · index ${num(t.receipt.transactionIndex)}` : 'pending'}</dd>
144
+ <dt>from</dt><dd>${who(L, t.from)} <span class="dim">${esc(t.from)}</span></dd>
145
+ <dt>to</dt><dd>${t.to ? `${who(L, t.to)} <span class="dim">${esc(t.to)}</span>` : `<span class="dim">created</span> ${esc(t.receipt?.contractAddress ?? '')}`}</dd>
146
+ <dt>value</dt><dd>${formatEth(t.value)} ETH</dd>
147
+ <dt>nonce</dt><dd>${num(t.nonce)}</dd>
148
+ <dt>gas</dt><dd>${t.receipt ? `${num(t.receipt.gasUsed)} used` : ''} of ${num(t.gas)} limit${t.receipt ? ` · ${esc(formatGwei(t.receipt.effectiveGasPrice))} gwei/gas · fee ${esc(formatEth('0x' + (BigInt(t.receipt.gasUsed) * BigInt(t.receipt.effectiveGasPrice)).toString(16)))} ETH` : ''}</dd>
149
+ <dt>input</dt><dd>${t.method?.name ? `<span class="name">${esc(t.method.name)}</span>(<span class="args">${esc(fmtArgs(t.method.args))}</span>)<br>` : ''}<span class="dim">${esc(t.input === '0x' ? '0x (no data)' : t.input)}</span></dd>
150
+ ${t.revertData && t.revertData !== '0x' ? `<dt>revert data</dt><dd class="dim">${esc(t.revertData)}</dd>` : ''}
151
+ </dl>
152
+ ${t.logs?.length ? `<ul class="events" data-testid="tx-events">${t.logs.map((l: any) => `<li><span class="dim">#${num(l.logIndex)}</span> ${who(L, l.address)} ${l.decoded ? `<span class="name">${esc(l.decoded.name)}</span>(<span class="args">${esc(fmtArgs(l.decoded.args))}</span>)` : `<span class="dim">topics ${l.topics.map(esc).join(' ')}<br>data ${esc(l.data)}</span>`}</li>`).join('')}</ul>` : `<div class="dim" style="margin-top:8px">no events</div>`}
153
+ </td></tr>`;
154
+ return `<tr class="tx${isOpen ? ' open' : ''}" data-testid="tx-row" data-hash="${t.hash}" data-status="${t.status}">
155
+ <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>
157
+ <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
+ }).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>`);
163
+ place();
164
+ };
165
+ panel.addEventListener('change', (e) => { const sel = e.target as HTMLSelectElement; if (sel.dataset.testid === 'tx-filter') { filter = sel.value as typeof filter; lastRender = ''; refreshTxs(); } });
166
+ panel.addEventListener('click', (e) => {
167
+ const row = (e.target as HTMLElement).closest<HTMLElement>('tr.tx'); if (!row) return;
168
+ const h = row.dataset.hash!; open.has(h) ? open.delete(h) : open.add(h); lastRender = ''; refreshTxs();
169
+ });
170
+ const refreshTxs = async () => { const data = await rpc('terrarium_transactions', [{ limit: 200 }]).catch(() => null); if (data) render(data); };
171
+
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(); });
174
+ bReset.classList.add('danger');
175
+ const pill = el(`<button id="terrarium-devbar-show" data-testid="show" title="Show the Terrarium dev bar" hidden>🌱</button>`);
176
+ 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));
184
+ bHide.classList.add('quiet');
185
+ pill.onclick = () => setHidden(false);
186
+ const gTools = group('', 'group-tools', bTxs, bReset, bHide);
187
+
188
+ bar.append(brand, status, el('<span class="spacer"></span>'), gChain, gWallet, gScenario, gTools, panel);
189
+ document.body.append(bar, pill);
190
+ 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
192
+
193
+ // ---- polling: status every 500 ms, the explorer while open, the scenario list now and then -----------------------------------
194
+ const refreshScenarios = async () => {
195
+ const s = await rpc('terrarium_scenarios').catch(() => null); if (!s) return;
196
+ const k = JSON.stringify(s); if (k === scenariosKey) return; scenariosKey = k;
197
+ const many = s.scenarios.length > 1;
198
+ scenarioSelect.hidden = !many;
199
+ const name = brand.querySelector<HTMLElement>('[data-testid=scenario-name]')!;
200
+ name.hidden = many || !s.active || /^Scenario \d+$/.test(s.active); name.textContent = s.active ?? '';
201
+ if (many) {
202
+ scenarioSelect.replaceChildren(...s.scenarios.map((sc: any) => { const o = document.createElement('option'); o.value = sc.name; o.textContent = sc.name; if (sc.description) o.title = sc.description; o.selected = sc.name === s.active; return o; }));
203
+ const active = s.scenarios.find((sc: any) => sc.name === s.active); if (active?.description) scenarioSelect.title = `${active.description}\n\nSwitching stores the choice and reloads the page; each scenario keeps its own chain.`;
204
+ }
205
+ };
54
206
  const refresh = async () => {
55
207
  const s = await rpc('terrarium_status').catch(() => null); if (!s) return;
56
- info.querySelector('[data-f=chain]')!.textContent = String(s.chainId);
57
- info.querySelector('[data-f=block]')!.textContent = String(parseInt(s.block, 16));
58
- info.querySelector('[data-f=engine]')!.textContent = 'revm/wasm' + (s.fork ? ` · fork @${s.fork.blockNumber}${s.fork.offline ? ' offline' : ''}${s.fork.misses ? ` · ${s.fork.misses} MISSES` : ''}` : '')
59
- + (s.http?.routes ? ` · ${s.http.routes} HTTP route${s.http.routes === 1 ? '' : 's'}, ${s.http.hits} answered` : '')
60
- + (s.restoredFromPersistence ? ` · ${s.localBlocks} local block${s.localBlocks === 1 ? '' : 's'} restored from a previous session (Reset to start clean)` : '');
208
+ me = s.accounts?.[0] ?? me;
209
+ status.querySelector('[data-f=chain]')!.textContent = String(s.chainId);
210
+ status.querySelector('[data-f=block]')!.textContent = String(parseInt(s.block, 16));
211
+ const notes: string[] = [];
212
+ 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
+ if (s.http?.routes) notes.push(`${s.http.routes} HTTP route${s.http.routes === 1 ? '' : 's'}, ${s.http.hits} answered`);
214
+ if (s.restoredFromPersistence) notes.push(`${s.localBlocks} block${s.localBlocks === 1 ? '' : 's'} restored`);
215
+ status.querySelector('[data-f=engine]')!.innerHTML = notes.length ? ' · ' + notes.join(' · ') : '';
216
+ status.title = `revm/wasm · the chain persists in IndexedDB${s.restoredFromPersistence ? ' (Reset to start clean)' : ''}`;
61
217
  const ck = JSON.stringify(s.controls ?? []);
62
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 ?? [])))); }
63
219
  bActors.hidden = !s.hasActors; bActors.textContent = `${s.actorsLabel} ${s.actors ? 'on' : 'off'}`; bActors.classList.toggle('on', s.actors);
220
+ gScenario.hidden = !s.hasActors && !(s.controls?.length);
64
221
  bReject.textContent = s.wallet.rejectNext > 0 ? `Reject next tx · armed (${s.wallet.rejectNext})` : 'Reject next tx'; bReject.classList.toggle('armed', s.wallet.rejectNext > 0);
65
222
  bLatency.textContent = s.wallet.latencyMs ? `Wallet: ${s.wallet.latencyMs / 1000}s delay` : 'Wallet: instant'; bLatency.classList.toggle('on', !!s.wallet.latencyMs);
66
223
  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(); }
67
225
  };
68
- refresh(); setInterval(refresh, 500);
226
+ refresh(); refreshScenarios();
227
+ let ticks = 0;
228
+ const timer = setInterval(() => { if (!document.getElementById('terrarium-devbar')) return clearInterval(timer); refresh(); if (++ticks % 10 === 0) refreshScenarios(); }, 500);
229
+ }
230
+
231
+ /** Remove the dev bar, its explorer and the show pill; restore the page's bottom padding. Idempotent. */
232
+ export function unmountDevBar() {
233
+ document.getElementById('terrarium-devbar')?.remove();
234
+ document.getElementById('terrarium-devbar-show')?.remove();
235
+ document.body?.style.removeProperty('padding-bottom');
69
236
  }
package/src/engine.js CHANGED
@@ -66,7 +66,7 @@ class RecordingRPCStateManager extends RPCStateManager {
66
66
  async getStorage(address, key) { if (this.knownAbsent(address) && this._caches.storage.get(address, key) === undefined) return new Uint8Array(); const rk = `${address.toString()}_${bytesToHex(key)}`; if (this._caches.storage.get(address, key) === undefined && this.remote.storage.has(rk)) { const v = this.remote.storage.get(rk); this._caches.storage.put(address, key, v); return v; } if (this.offline && this._caches.storage.get(address, key) === undefined) this.miss('storage', `${address.toString()}:${bytesToHex(key)}`); if (globalThis.process?.env?.TERRARIUM_DEBUG && this._caches.storage.get(address, key) === undefined) console.log('[remote] storage', address.toString(), bytesToHex(key)); const v = await this.retry(() => super.getStorage(address, key)); this.remote.storage.set(`${address.toString()}_${bytesToHex(key)}`, v); return v; }
67
67
  }
68
68
 
69
- const STATE_CHANGING = new Set(['eth_sendTransaction', 'eth_sendRawTransaction', 'evm_mine', 'anvil_mine', 'hardhat_mine', 'evm_setNextBlockTimestamp', 'anvil_setNextBlockTimestamp', 'evm_increaseTime', 'anvil_increaseTime', 'evm_setAutomine', 'anvil_setAutomine', 'anvil_setBalance', 'hardhat_setBalance', 'anvil_setCode', 'hardhat_setCode', 'anvil_setNonce', 'hardhat_setNonce', 'anvil_setStorageAt', 'hardhat_setStorageAt', 'anvil_impersonateAccount', 'hardhat_impersonateAccount', 'anvil_stopImpersonatingAccount', 'hardhat_stopImpersonatingAccount', 'anvil_setNextBlockBaseFeePerGas', 'hardhat_setNextBlockBaseFeePerGas', 'sim_deal', 'sim_setState']);
69
+ const STATE_CHANGING = new Set(['eth_sendTransaction', 'eth_sendRawTransaction', 'evm_mine', 'anvil_mine', 'hardhat_mine', 'evm_setNextBlockTimestamp', 'anvil_setNextBlockTimestamp', 'evm_increaseTime', 'anvil_increaseTime', 'evm_setAutomine', 'anvil_setAutomine', 'anvil_setBalance', 'hardhat_setBalance', 'anvil_setCode', 'hardhat_setCode', 'anvil_setNonce', 'hardhat_setNonce', 'anvil_setStorageAt', 'hardhat_setStorageAt', 'anvil_loadState', 'hardhat_loadState', 'anvil_impersonateAccount', 'hardhat_impersonateAccount', 'anvil_stopImpersonatingAccount', 'hardhat_stopImpersonatingAccount', 'anvil_setNextBlockBaseFeePerGas', 'hardhat_setNextBlockBaseFeePerGas', 'sim_deal', 'sim_setState']);
70
70
  const pad32 = (h) => pad(typeof h === 'bigint' ? numberToHex(h, { size: 32 }) : h, { size: 32 });
71
71
 
72
72
  /** small deterministic PRNG (mulberry32) so scripted actors are reproducible when a seed is given */
@@ -364,11 +364,12 @@ export async function createTerrarium(opts = {}) {
364
364
  r = await exec({ tx: entry.tx, block, flags: { blockGasUsed: cumulative } });
365
365
  } catch (e) { // invalid tx (nonce too low, insufficient funds...): a node would drop it silently and the dapp
366
366
  // would wait for a receipt forever. Record a failed receipt instead so waiters resolve and the reason is visible.
367
- const t = txs.get(entry.hash); t.error = String(e.message);
367
+ const t = txs.get(entry.hash); t.error = String(e.message); t.dropped = true;
368
368
  receipts.push({ transactionHash: entry.hash, transactionIndex: hex(idx), from: entry.from, to: entry.rpc.to, cumulativeGasUsed: hex(cumulative), gasUsed: '0x0', effectiveGasPrice: hex(baseFee), contractAddress: null, logs: [], logsBloom: '0x' + '00'.repeat(256), status: '0x0', type: '0x2', droppedReason: t.error });
369
369
  hashes.push(entry.hash); sealTxs.push(entry.tx); sealReceipts.push({ status: 0, cumulativeBlockGasUsed: cumulative, bitvector: new Uint8Array(256), logs: [] }); continue;
370
370
  }
371
371
  cumulative += r.gasUsed;
372
+ if (!r.success) { const t = txs.get(entry.hash); t.error = r.error ?? 'execution reverted'; t.revertData = bytesToHex(r.returnValue); }
372
373
  if (globalThis.process?.env?.TERRARIUM_DEBUG && !r.success) console.log('[tx reverted]', entry.hash, r.error, bytesToHex(r.returnValue), 'gasUsed', r.gasUsed, 'gasLimit', entry.tx.gasLimit);
373
374
  const bloom = bloomOf(r.logs);
374
375
  const logs = r.logs.map(([addr, topics, data]) => ({ address: bytesToHex(addr), topics: topics.map(bytesToHex), data: bytesToHex(data), blockNumber: hex(header.number), transactionHash: entry.hash, transactionIndex: hex(idx), logIndex: hex(logIndex++), removed: false }));
@@ -447,6 +448,19 @@ export async function createTerrarium(opts = {}) {
447
448
  return hi;
448
449
  }
449
450
 
451
+ /** Transactions newest first, as an explorer lists them: the RPC tx merged with its receipt, a `status` word, the
452
+ * revert reason and data of a failed one, and the block timestamp. Pending (interval mining) come first. */
453
+ 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)) };
462
+ }
463
+
450
464
  // ---- RPC formatting ----------------------------------------------------------------------------
451
465
  function rpcBlock(b, full) {
452
466
  const transactions = full ? b.transactions.map((h) => txs.get(h).rpc) : b.transactions;
@@ -540,6 +554,7 @@ export async function createTerrarium(opts = {}) {
540
554
  case 'eth_sendRawTransaction': { const tx = createTxFromRLP(hexToBytes(params[0]), { common }); return submit(tx, tx.getSenderAddress().toString()); }
541
555
  case 'eth_getTransactionReceipt': { const t = txs.get(params[0]); if (!t?.receipt) return null; if (walletKnobs.receiptLagMs > 0 && Date.now() - (t.minedAt ?? 0) < walletKnobs.receiptLagMs) return null; return t.receipt; }
542
556
  case 'eth_getTransactionByHash': return txs.get(params[0])?.rpc ?? null;
557
+ case 'terrarium_transactions': return listTransactions(params[0] ?? {});
543
558
  case 'eth_getLogs': return getLogs(params[0] ?? {});
544
559
  case 'eth_newFilter': filters.set(hex(nextFilterId), { type: 'logs', f: params[0] ?? {}, cursor: latest().number + 1n }); return hex(nextFilterId++);
545
560
  case 'eth_newBlockFilter': filters.set(hex(nextFilterId), { type: 'blocks', cursor: latest().number + 1n }); return hex(nextFilterId++);
@@ -568,6 +583,7 @@ export async function createTerrarium(opts = {}) {
568
583
  case 'anvil_setCode': case 'hardhat_setCode': await sm.putCode(createAddressFromString(params[0]), hexToBytes(params[1])); return null;
569
584
  case 'anvil_setNonce': case 'hardhat_setNonce': await sm.modifyAccountFields(createAddressFromString(params[0]), { nonce: hexToBigInt(params[1]) }); return null;
570
585
  case 'anvil_setStorageAt': case 'hardhat_setStorageAt': await sm.putStorage(createAddressFromString(params[0]), hexToBytes(numberToHex(hexToBigInt(params[1]), { size: 32 })), hexToBytes(numberToHex(hexToBigInt(params[2]), { size: 32 }))); return null;
586
+ case 'anvil_loadState': case 'hardhat_loadState': return loadAnvilState(params[0]);
571
587
  case 'anvil_impersonateAccount': case 'hardhat_impersonateAccount': impersonated.add(params[0].toLowerCase()); return null;
572
588
  case 'anvil_stopImpersonatingAccount': case 'hardhat_stopImpersonatingAccount': impersonated.delete(params[0].toLowerCase()); return null;
573
589
  case 'anvil_setNextBlockBaseFeePerGas': case 'hardhat_setNextBlockBaseFeePerGas': baseFee = hexToBigInt(params[0]); return null;
@@ -694,6 +710,37 @@ export async function createTerrarium(opts = {}) {
694
710
  return written;
695
711
  }
696
712
 
713
+ // ---- anvil_loadState: an Anvil state dump (anvil_dumpState / --dump-state) written into this chain ------------------
714
+ // Accepts what Anvil hands out: the gzipped JSON as a hex string, or the parsed object. Every account's code, nonce,
715
+ // balance and storage land through the state manager like the single cheatcodes do, in one journaled call. Deploy a
716
+ // protocol with its own tooling (Foundry scripts, Hardhat deploys) against Anvil, dump, and boot the Terrarium from it.
717
+ async function loadAnvilState(input) {
718
+ const dump = typeof input === 'string' ? await parseAnvilDump(input) : input;
719
+ const accounts = dump?.accounts ?? dump;
720
+ if (!accounts || typeof accounts !== 'object') throw new RpcError(-32602, 'anvil_loadState: expected an Anvil state dump ({ accounts: { address: { nonce, balance, code, storage } } })');
721
+ let n = 0, slots = 0;
722
+ for (const [a, acct] of Object.entries(accounts)) {
723
+ if (!acct) continue;
724
+ const addr = createAddressFromString(a);
725
+ if (acct.code && acct.code !== '0x') await sm.putCode(addr, hexToBytes(acct.code));
726
+ const fields = {};
727
+ if (acct.nonce !== undefined) fields.nonce = BigInt(acct.nonce);
728
+ if (acct.balance !== undefined) fields.balance = BigInt(acct.balance);
729
+ if (Object.keys(fields).length) await sm.modifyAccountFields(addr, fields);
730
+ for (const [k, v] of Object.entries(acct.storage ?? {})) { await sm.putStorage(addr, hexToBytes(pad32(hexToBigInt(k))), hexToBytes(pad32(hexToBigInt(v)))); slots++; }
731
+ n++;
732
+ }
733
+ return { accounts: n, slots };
734
+ }
735
+ /** Anvil's wire format: hex of gzipped JSON. Inflated with the platform's DecompressionStream (browsers, Workers, Node 18+). */
736
+ async function parseAnvilDump(hexDump) {
737
+ const bytes = hexToBytes(hexDump);
738
+ if (bytes[0] !== 0x1f || bytes[1] !== 0x8b) return JSON.parse(new TextDecoder().decode(bytes)); // not gzipped: plain JSON as hex
739
+ if (typeof DecompressionStream === 'undefined') throw new RpcError(-32000, 'anvil_loadState: gzipped dump but no DecompressionStream here; pass the parsed object');
740
+ const inflated = await new Response(new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip'))).text();
741
+ return JSON.parse(inflated);
742
+ }
743
+
697
744
  // ---- persistence: dump the diff, restore it, or replay the journal -------------------------------
698
745
  const serBlock = (b) => ({ ...b, number: hex(b.number), timestamp: hex(b.timestamp), gasLimit: hex(b.gasLimit), gasUsed: hex(b.gasUsed), baseFeePerGas: hex(b.baseFeePerGas) });
699
746
  const deserBlock = (b) => ({ ...b, number: hexToBigInt(b.number), timestamp: hexToBigInt(b.timestamp), gasLimit: hexToBigInt(b.gasLimit), gasUsed: hexToBigInt(b.gasUsed), baseFeePerGas: hexToBigInt(b.baseFeePerGas) });
@@ -706,7 +753,7 @@ export async function createTerrarium(opts = {}) {
706
753
  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;
707
754
  return { version: 1, chainId, savedAt: Date.now(), state: { accounts: accountsOut, code: codeOut, storage: storageOut }, remote,
708
755
  // tx bodies: only the most recent blocks' worth (like a pruned node), and without their logs (rebuilt from block logs on load)
709
- 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 }])), timeOffset: hex(timeOffset), baseFee: hex(baseFee), mining, impersonated: [...impersonated], dealtSlots: Object.fromEntries(dealtSlots) },
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) },
710
757
  journal: { entries: journal, timestamps: blocks.slice(1).map((b) => hex(b.timestamp)) } };
711
758
  }
712
759
  async function seedState({ accounts: accs = {}, code = {}, storage = {} }) {
@@ -763,6 +810,8 @@ export async function createTerrarium(opts = {}) {
763
810
  dumpState: () => exclusive(dumpState), loadState: (d) => exclusive(() => loadState(d)), replayJournal: (j) => exclusive(() => replayJournal(j)),
764
811
  get journal() { return journal.slice(); }, flush: () => { clearTimeout(persistTimer); return persister ? exclusive(async () => persister.setItem(persistKey, JSON.stringify(await dumpState()))) : Promise.resolve(); },
765
812
  get blockNumber() { return latest().number; },
813
+ /** transactions newest first ({ total, transactions }): tx + receipt + status word + revert reason/data + block timestamp */
814
+ transactions: (o) => listTransactions(o),
766
815
  /** React to on-chain events with scripted actors (keepers, oracles, other users, bridges...). */
767
816
  onLog(filter, handler) { const l = { filter, handler }; logListeners.push(l); return () => logListeners.splice(logListeners.indexOf(l), 1); },
768
817
  /** Cheat: send a tx "from" any address without keys (impersonation), e.g. to simulate another user. */
package/src/inject.ts CHANGED
@@ -2,14 +2,15 @@
2
2
  // wallet (so the dapp's own connect modal lists "Terrarium Wallet") and mount the dev bar. Nothing here is imported by
3
3
  // the dapp; this is the analogue of a browser extension's injected script.
4
4
  import { createWorkerProvider } from './bridge.ts';
5
- import { mountDevBar } from './devbar.ts';
5
+ import { mountDevBar, unmountDevBar } from './devbar.ts';
6
6
  import { installHttpInterceptor, type WireRoute } from './http.ts';
7
7
 
8
8
  const ICON = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#1F6F5C"/><path d="M6 25.5c2-6 5.5-8.5 10-8.5s8 2.5 10 8.5" fill="none" stroke="#E8C547" stroke-width="2.5" stroke-linecap="round"/><path d="M16 17.5V9" stroke="#E8C547" stroke-width="2.5" stroke-linecap="round"/><path d="M16 12c0-4.5 3-7 7-7 0 4.5-3 7-7 7Z M16 14.5c0-4.5-3-7-7-7 0 4.5 3 7 7 7Z" fill="#E8C547"/></svg>');
9
9
 
10
10
  export interface StartOptions {
11
- /** mount the dev bar (default true); false for a host that draws its own controls */
12
- devBar?: boolean;
11
+ /** mount the dev bar (default true); 'hidden' mounts it collapsed to the leaf at the bottom right (a Hide/Show click is
12
+ * remembered and wins); false for a host that draws its own controls */
13
+ devBar?: boolean | 'hidden';
13
14
  }
14
15
  let current: { worker: Worker; announce: () => void } | null = null;
15
16
 
@@ -20,6 +21,8 @@ export function startTerrarium(worker: Worker, opts: StartOptions = {}) {
20
21
  // the RPC is the fallback. Until either arrives, the dapp's fetches wait; nothing else about them changes.
21
22
  const routes = new Promise<WireRoute[]>((res) => { provider.on('httpRoutes', (r) => res(r as WireRoute[])); provider.request({ method: 'terrarium_httpRoutes' }).then((r) => res(r as WireRoute[]), () => res([])); });
22
23
  installHttpInterceptor(provider, routes);
24
+ // a scenario method that reset or rebuilt the chain asks the page to start over (ctx.reload())
25
+ provider.on('reload', () => window.location.reload());
23
26
  const detail = Object.freeze({ info: { uuid: '7e44a1c0-5f0b-4c1e-9b7a-a1b2c3d4e5f6', name: 'Terrarium Wallet', icon: ICON, rdns: 'dev.terrarium' }, provider });
24
27
  const announce = () => window.dispatchEvent(new CustomEvent('eip6963:announceProvider', { detail }));
25
28
  window.addEventListener('eip6963:requestProvider', announce);
@@ -27,7 +30,7 @@ export function startTerrarium(worker: Worker, opts: StartOptions = {}) {
27
30
  current = { worker, announce };
28
31
  // the wallet's own global (like window.ethereum) — for tests and the console, never for the dapp
29
32
  (window as any).terrarium = { provider, request: (method: string, params: unknown[] = []) => provider.request({ method, params }) };
30
- if (opts.devBar !== false) { const mount = () => mountDevBar(provider); if (document.body) mount(); else document.addEventListener('DOMContentLoaded', mount); }
33
+ if (opts.devBar !== false) { const mount = () => mountDevBar(provider, { hidden: opts.devBar === 'hidden' }); if (document.body) mount(); else document.addEventListener('DOMContentLoaded', mount); }
31
34
  return provider;
32
35
  }
33
36
 
@@ -38,7 +41,6 @@ export function stopTerrarium() {
38
41
  window.removeEventListener('eip6963:requestProvider', current.announce);
39
42
  current.worker.terminate();
40
43
  current = null;
41
- document.getElementById('terrarium-devbar')?.remove();
42
- document.body?.style.removeProperty('padding-bottom');
44
+ unmountDevBar();
43
45
  delete (window as any).terrarium;
44
46
  }
@@ -0,0 +1,79 @@
1
+ // known-abi.ts — what the transaction explorer decodes with no configuration: the events, functions and errors that
2
+ // most protocols share (token standards, WETH, Uniswap V2 pairs, Ownable, proxies, OpenZeppelin's custom errors).
3
+ // A scenario's own `abis` and per-address `ctx.label(address, name, abi)` take precedence; this is the fallback.
4
+ import { parseAbi } from 'viem';
5
+
6
+ export const KNOWN_ABI = parseAbi([
7
+ // ERC-20 / ERC-721 / ERC-1155
8
+ 'event Transfer(address indexed from, address indexed to, uint256 value)',
9
+ 'event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)',
10
+ 'event Approval(address indexed owner, address indexed spender, uint256 value)',
11
+ 'event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId)',
12
+ 'event ApprovalForAll(address indexed owner, address indexed operator, bool approved)',
13
+ 'event TransferSingle(address indexed operator, address indexed from, address indexed to, uint256 id, uint256 value)',
14
+ 'event TransferBatch(address indexed operator, address indexed from, address indexed to, uint256[] ids, uint256[] values)',
15
+ 'function transfer(address to, uint256 value) returns (bool)',
16
+ 'function transferFrom(address from, address to, uint256 value) returns (bool)',
17
+ 'function approve(address spender, uint256 value) returns (bool)',
18
+ 'function mint(address to, uint256 amount)',
19
+ 'function burn(uint256 amount)',
20
+ 'function safeTransferFrom(address from, address to, uint256 tokenId)',
21
+ 'function setApprovalForAll(address operator, bool approved)',
22
+ 'function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s)',
23
+ // WETH
24
+ 'event Deposit(address indexed dst, uint256 wad)',
25
+ 'event Withdrawal(address indexed src, uint256 wad)',
26
+ 'function deposit()',
27
+ 'function withdraw(uint256 wad)',
28
+ // ERC-4626
29
+ 'event Deposit(address indexed sender, address indexed owner, uint256 assets, uint256 shares)',
30
+ 'event Withdraw(address indexed sender, address indexed receiver, address indexed owner, uint256 assets, uint256 shares)',
31
+ 'function deposit(uint256 assets, address receiver) returns (uint256)',
32
+ 'function mint(uint256 shares, address receiver) returns (uint256)',
33
+ 'function withdraw(uint256 assets, address receiver, address owner) returns (uint256)',
34
+ 'function redeem(uint256 shares, address receiver, address owner) returns (uint256)',
35
+ // Uniswap V2 pair / factory
36
+ 'event Swap(address indexed sender, uint256 amount0In, uint256 amount1In, uint256 amount0Out, uint256 amount1Out, address indexed to)',
37
+ 'event Sync(uint112 reserve0, uint112 reserve1)',
38
+ 'event Mint(address indexed sender, uint256 amount0, uint256 amount1)',
39
+ 'event Burn(address indexed sender, uint256 amount0, uint256 amount1, address indexed to)',
40
+ 'event PairCreated(address indexed token0, address indexed token1, address pair, uint256)',
41
+ // Uniswap V3 pool
42
+ 'event Swap(address indexed sender, address indexed recipient, int256 amount0, int256 amount1, uint160 sqrtPriceX96, uint128 liquidity, int24 tick)',
43
+ // Ownable, pausable, proxies, roles
44
+ 'event OwnershipTransferred(address indexed previousOwner, address indexed newOwner)',
45
+ 'event Paused(address account)',
46
+ 'event Unpaused(address account)',
47
+ 'event Upgraded(address indexed implementation)',
48
+ 'event AdminChanged(address previousAdmin, address newAdmin)',
49
+ 'event BeaconUpgraded(address indexed beacon)',
50
+ 'event Initialized(uint64 version)',
51
+ 'event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender)',
52
+ 'event RoleRevoked(bytes32 indexed role, address indexed account, address indexed sender)',
53
+ 'function transferOwnership(address newOwner)',
54
+ 'function renounceOwnership()',
55
+ 'function upgradeTo(address newImplementation)',
56
+ 'function upgradeToAndCall(address newImplementation, bytes data)',
57
+ 'function multicall(bytes[] data) returns (bytes[])',
58
+ // OpenZeppelin 5 custom errors
59
+ 'error ERC20InsufficientBalance(address sender, uint256 balance, uint256 needed)',
60
+ 'error ERC20InvalidSender(address sender)',
61
+ 'error ERC20InvalidReceiver(address receiver)',
62
+ 'error ERC20InsufficientAllowance(address spender, uint256 allowance, uint256 needed)',
63
+ 'error ERC20InvalidApprover(address approver)',
64
+ 'error ERC20InvalidSpender(address spender)',
65
+ 'error ERC721InvalidOwner(address owner)',
66
+ 'error ERC721NonexistentToken(uint256 tokenId)',
67
+ 'error ERC721IncorrectOwner(address sender, uint256 tokenId, address owner)',
68
+ 'error ERC721InsufficientApproval(address operator, uint256 tokenId)',
69
+ 'error OwnableUnauthorizedAccount(address account)',
70
+ 'error OwnableInvalidOwner(address owner)',
71
+ 'error EnforcedPause()',
72
+ 'error ExpectedPause()',
73
+ 'error ReentrancyGuardReentrantCall()',
74
+ 'error AccessControlUnauthorizedAccount(address account, bytes32 neededRole)',
75
+ 'error SafeERC20FailedOperation(address token)',
76
+ 'error AddressEmptyCode(address target)',
77
+ 'error FailedCall()',
78
+ 'error InsufficientBalance(uint256 balance, uint256 needed)',
79
+ ]);
package/src/scenario.ts CHANGED
@@ -6,13 +6,16 @@
6
6
  // actors: [{ every: 5000, run: (ctx) => ... }, { on: { address, topics }, run: (ctx, log) => ... }],
7
7
  // status: (ctx) => ({ addresses: ctx.state }),
8
8
  // });
9
- import type { Account, Address, Chain, Hex, PublicClient, TransactionReceipt, Transport, WalletClient } from 'viem';
9
+ import type { Abi, Account, Address, Chain, Hex, PublicClient, TransactionReceipt, Transport, WalletClient } from 'viem';
10
10
  import type { HttpRoute } from './http.ts';
11
11
  export { reply } from './http.ts';
12
12
  export type { HttpRoute, HttpRequest, HttpReply, GraphqlQuery, GraphqlResolver } from './http.ts';
13
13
 
14
14
  /** Runtime bytecode of deployed contracts, installed at fixed addresses (`terrarium fetch-code` produces these). */
15
- export interface Fixture { contracts: Record<string, { address: string; code: string }> } // plain strings: JSON imports fit as-is
15
+ export interface Fixture extends FixtureMeta { contracts: Record<string, { address: string; code: string }> } // plain strings: JSON imports fit as-is
16
+ /** what any fixture may add for the transaction explorer: names and ABIs by address (`terrarium import-anvil --broadcast --artifacts`
17
+ * fills them from a Foundry deployment). `install(fixture)` registers them like `ctx.label` would */
18
+ export interface FixtureMeta { names?: Record<string, string>; abis?: Record<string, Abi | readonly unknown[]> }
16
19
  export interface LogFilter { address?: Address | Address[]; topics?: (Hex | Hex[] | null)[] }
17
20
 
18
21
  export interface ScenarioContext {
@@ -36,14 +39,31 @@ export interface ScenarioContext {
36
39
  /** true when nothing was persisted yet (first boot or after a reset), even if a fixture was restored: seed the user once */
37
40
  firstBoot: boolean;
38
41
  codeAt(address: Address): Promise<Hex>;
39
- /** put a fixture's bytecode at its addresses (skips contracts already present, so it is safe on every boot) */
40
- install(fixture: Fixture): Promise<void>;
42
+ /** put a fixture on the chain, safe on every boot. A code fixture (`terrarium fetch-code`) writes each contract's
43
+ * bytecode where there is none yet. An Anvil state dump (`terrarium import-anvil`, or `anvil_dumpState` by hand)
44
+ * writes whole accounts (code, storage, nonce, balance) through `anvil_loadState`: contracts that already have code
45
+ * are skipped, accounts without code (the deployer's nonce, funded EOAs) are only written on a fresh chain */
46
+ install(fixture: Fixture | AnvilStateFixture): Promise<void>;
47
+ /** name an address in the transaction explorer and, optionally, give the ABI that decodes calls to it, its events and its
48
+ * custom errors: `ctx.label(pair, 'PEPE/WETH pair', pairAbi)`. `install(fixture)` labels contracts by their fixture keys */
49
+ label(address: Address | string, name: string, abi?: Abi): void;
50
+ /** ask the page to reload: for a method that reset or rebuilt the chain and wants the dapp to start over on it */
51
+ reload(): void;
41
52
  /** a bag for whatever setup() discovers (addresses...) that actors and status() need later */
42
53
  state: Record<string, any>;
43
54
  }
44
55
 
56
+ /** what `anvil_dumpState` / `anvil --dump-state` produce (and `terrarium import-anvil` writes): whole accounts */
57
+ export interface AnvilStateFixture extends FixtureMeta {
58
+ accounts: Record<string, { nonce?: number | string; balance?: string; code?: Hex; storage?: Record<string, string> } | null>;
59
+ [extra: string]: unknown;
60
+ }
61
+
45
62
  export interface Actor {
46
63
  name?: string;
64
+ /** run from the first boot, outside the actors toggle: a keeper the protocol cannot work without (an oracle answering
65
+ * requests) belongs here; other people trading belongs in the toggled actors */
66
+ always?: boolean;
47
67
  /** run every N ms */
48
68
  every?: number;
49
69
  /** ...or run when a matching log is mined (a function, if the filter depends on setup() results) */
@@ -52,6 +72,11 @@ export interface Actor {
52
72
  }
53
73
 
54
74
  export interface ScenarioConfig {
75
+ /** shown in the dev bar; with several scenarios (`export default [a, b]`) it is what the selector lists and what
76
+ * `terrarium_selectScenario` takes. Default: `Scenario <n>`. Its slug is the default `persist` key of a listed scenario */
77
+ name?: string;
78
+ /** one line under the name in the selector: what this scenario puts the UI through */
79
+ description?: string;
55
80
  chainId?: number;
56
81
  /** seed for ctx.random() and the actors; omit for a fresh seed per boot */
57
82
  seed?: number;
@@ -89,6 +114,18 @@ export interface ScenarioConfig {
89
114
  * intercepted for matching URLs, the handler runs here in the Worker with `ctx`, everything else goes to the network.
90
115
  * http: [{ match: 'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v2', graphql: { swaps: (ctx, q) => … } }] */
91
116
  http?: HttpRoute[];
117
+ /** extra ABIs for the transaction explorer (`terrarium_transactions`): calls, events and custom errors decode with an
118
+ * address's own ABI (`ctx.label(address, name, abi)`) first, then these, then the built-in set of standard events, functions
119
+ * and errors (ERC-20/721/1155/4626, WETH, Uniswap V2, Ownable, proxies, OpenZeppelin errors). Anything left is shown raw */
120
+ abis?: Abi[];
121
+ /** names for addresses known up front (`{ [ROUTER]: 'Uniswap V2 Router' }`); for addresses discovered in setup use
122
+ * `ctx.label(address, name)`. The sim's accounts are `Account #i` unless named */
123
+ labels?: Record<string, string>;
92
124
  }
93
125
 
94
126
  export function defineScenario(config: ScenarioConfig): ScenarioConfig { return config; }
127
+ /** several scenarios in one file: the dev bar shows a selector, each keeps its own persisted chain, the choice survives
128
+ * reloads. `export default defineScenarios([fresh, afterCrash, indexerDown])`; a plain array works too */
129
+ export function defineScenarios(list: ScenarioConfig[]): ScenarioConfig[] { return list; }
130
+ /** what the Worker entry hands to `runScenario`: one scenario or a list */
131
+ export type ScenarioInput = ScenarioConfig | ScenarioConfig[];
@@ -0,0 +1,47 @@
1
+ // transport.ts — a viem transport over the Terrarium Wallet, for dapps whose reads go through a configured transport
2
+ // rather than the connected wallet (wagmi's `createConfig({ transports })`, a `createPublicClient` per chain).
3
+ //
4
+ // The Terrarium has no RPC server: the wallet provider is the node. A dapp on viem alone can `custom()` the provider it
5
+ // discovered through EIP-6963; a wagmi dapp declares one transport per chain up front, before any wallet is known, and
6
+ // needs one that finds the provider when the first request comes. That is all this is: `custom()` over the EIP-6963
7
+ // announcement with rdns `dev.terrarium`, resolved lazily and once. It imports nothing of the engine, so a dapp that
8
+ // uses it ships a few dozen lines, not the simulator; still, keep it behind the same guard as the mount, since a page
9
+ // without a Terrarium has nothing to find and reads on that chain fail like reads on an unreachable RPC.
10
+ import { custom, type CustomTransport } from 'viem';
11
+
12
+ export const TERRARIUM_RDNS = 'dev.terrarium';
13
+
14
+ export interface TerrariumTransportOptions {
15
+ /** how long to wait for the wallet's EIP-6963 announcement before a request fails (default 4000 ms) */
16
+ timeoutMs?: number;
17
+ }
18
+
19
+ let found: Promise<any> | null = null;
20
+
21
+ /** the Terrarium Wallet's EIP-1193 provider, discovered through EIP-6963 (or `window.terrarium` when already injected) */
22
+ export function discoverTerrarium(timeoutMs = 4000): Promise<any> {
23
+ if (typeof window === 'undefined') return Promise.reject(new Error('no window: the Terrarium lives in a page'));
24
+ const injected = (window as any).terrarium?.provider;
25
+ if (injected) return Promise.resolve(injected);
26
+ if (found) return found;
27
+ found = new Promise((resolve, reject) => {
28
+ const onAnnounce = (e: Event) => {
29
+ const detail = (e as CustomEvent).detail;
30
+ if (detail?.info?.rdns !== TERRARIUM_RDNS) return;
31
+ done(); resolve(detail.provider);
32
+ };
33
+ const timer = setTimeout(() => { done(); found = null; reject(new Error('Terrarium Wallet not found on this page')); }, timeoutMs);
34
+ const done = () => { clearTimeout(timer); window.removeEventListener('eip6963:announceProvider', onAnnounce); };
35
+ window.addEventListener('eip6963:announceProvider', onAnnounce);
36
+ window.dispatchEvent(new Event('eip6963:requestProvider'));
37
+ });
38
+ return found;
39
+ }
40
+
41
+ /** a viem transport whose requests go to the Terrarium Wallet: `transports: { [31337]: terrariumTransport() }` in wagmi */
42
+ export function terrariumTransport(opts: TerrariumTransportOptions = {}): CustomTransport {
43
+ return custom(
44
+ { request: async (args: { method: string; params?: unknown[] }) => (await discoverTerrarium(opts.timeoutMs)).request(args) },
45
+ { name: 'Terrarium', key: 'terrarium', retryCount: 0 },
46
+ );
47
+ }
@@ -3,11 +3,34 @@ import type { Plugin } from 'vite';
3
3
  export interface TerrariumPluginOptions {
4
4
  /** the scenario module (default export of defineScenario), relative to the Vite root. Default: terrarium.scenario.ts */
5
5
  scenario?: string;
6
+ /** the dev bar: true (default), 'hidden' (starts collapsed to the leaf; a Hide/Show click is remembered and wins), false (none) */
7
+ devBar?: boolean | 'hidden';
8
+ /** how the Terrarium reaches the page. 'script' (default): one module script injected into index.html, the app's
9
+ * source untouched. 'react': nothing injected; the app renders `TerrariumMount` from 'virtual:terrarium/react' at its
10
+ * root (a `@terrariumlabs/react` <Terrarium> over the generated Worker, so StrictMode, context and unmount are handled),
11
+ * and that module exports null when the Terrarium is off. Needs `@terrariumlabs/react` installed */
12
+ mount?: 'script' | 'react';
6
13
  }
7
14
 
8
- /** Injects the Terrarium (chain in a Worker + EIP-6963 wallet + dev bar) into index.html as a separate module script.
9
- * The dapp's source is untouched: remove the plugin, or set VITE_TERRARIUM=off, and nothing of it is built. */
15
+ /** Injects the Terrarium (chain in a Worker + EIP-6963 wallet + dev bar) into the page: a script tag by default, or a
16
+ * React mount the app imports from 'virtual:terrarium/react'. Remove the plugin, or set VITE_TERRARIUM=off, and nothing
17
+ * of it is built. */
10
18
  export function terrarium(opts?: TerrariumPluginOptions): Plugin;
11
19
 
12
20
  /** the Worker entry: the user's scenario + the runtime (also used by the CLI's standalone build) */
13
21
  export function workerEntry(scenarioImport: string): string;
22
+
23
+ /** the generated React mount module (what 'virtual:terrarium/react' resolves to when the Terrarium is on) */
24
+ export function reactMountEntry(devBar?: boolean | 'hidden'): string;
25
+
26
+ /** the virtual module id of the React mount */
27
+ export const REACT_MOUNT_ID: 'virtual:terrarium/react';
28
+
29
+ declare module 'virtual:terrarium/react' {
30
+ import type { ComponentType, ReactNode } from 'react';
31
+ /** true when the Terrarium is on in this build */
32
+ export const enabled: boolean;
33
+ /** the mount, or null when the Terrarium is off: `{TerrariumMount && <TerrariumMount />}` */
34
+ const TerrariumMount: ComponentType<{ children?: ReactNode }> | null;
35
+ export default TerrariumMount;
36
+ }
@@ -4,12 +4,20 @@ import { mkdirSync, writeFileSync } from 'node:fs';
4
4
  import { join, relative, resolve, sep } from 'node:path';
5
5
  import { loadEnv } from 'vite';
6
6
 
7
- /** Injects the Terrarium (chain in a Worker + EIP-6963 wallet + dev bar) into index.html as a separate module script.
8
- * The dapp's source is untouched: remove the plugin, or set VITE_TERRARIUM=off, and nothing of it is built.
7
+ /** the virtual module a React app imports its mount from when `mount: 'react'` */
8
+ export const REACT_MOUNT_ID = 'virtual:terrarium/react';
9
+ const REACT_MOUNT_OFF = '\0terrarium/react-off';
10
+
11
+ /** Injects the Terrarium (chain in a Worker + EIP-6963 wallet + dev bar) into the page.
12
+ * Default: one module script added to index.html; the dapp's source is untouched. `mount: 'react'`: nothing is injected
13
+ * and the app imports `TerrariumMount` from 'virtual:terrarium/react' and renders it at its root (a `@terrariumlabs/react`
14
+ * <Terrarium> over the generated Worker); when the Terrarium is off that module exports null, so the app renders
15
+ * nothing and bundles none of it. Remove the plugin, or set VITE_TERRARIUM=off, and nothing of it is built either way.
9
16
  * @param {import('./vite-plugin.d.ts').TerrariumPluginOptions} [opts]
10
17
  * @returns {import('vite').Plugin} */
11
18
  export function terrarium(opts = {}) {
12
- let enabled = true;
19
+ let enabled = true, reactMount = null;
20
+ const react = opts.mount === 'react';
13
21
  return {
14
22
  name: 'terrarium',
15
23
  configResolved(config) {
@@ -18,12 +26,27 @@ export function terrarium(opts = {}) {
18
26
  const scenario = '/' + relative(config.root, resolve(config.root, opts.scenario ?? 'terrarium.scenario.ts')).split(sep).join('/');
19
27
  const dir = resolve(config.root, '.terrarium'); mkdirSync(dir, { recursive: true });
20
28
  writeFileSync(join(dir, 'worker.ts'), workerEntry(scenario));
21
- writeFileSync(join(dir, 'inject.ts'), `import { startTerrarium } from '@terrariumlabs/core/inject';\nstartTerrarium(new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }));\n`);
29
+ if (react) {
30
+ reactMount = join(dir, 'react.tsx');
31
+ writeFileSync(reactMount, reactMountEntry(opts.devBar));
32
+ config.logger.info(`[terrarium] React mount at ${REACT_MOUNT_ID} (scenario ${scenario})`);
33
+ return;
34
+ }
35
+ const start = opts.devBar === undefined ? '' : `, { devBar: ${JSON.stringify(opts.devBar)} }`;
36
+ writeFileSync(join(dir, 'inject.ts'), `import { startTerrarium } from '@terrariumlabs/core/inject';\nstartTerrarium(new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' })${start});\n`);
22
37
  config.logger.info(`[terrarium] injecting the simulated chain (scenario ${scenario})`);
23
38
  },
39
+ resolveId(source) {
40
+ if (source !== REACT_MOUNT_ID) return null;
41
+ return enabled && react ? reactMount : REACT_MOUNT_OFF;
42
+ },
43
+ load(id) {
44
+ if (id !== REACT_MOUNT_OFF) return null;
45
+ return 'export const enabled = false;\nexport default null;\n';
46
+ },
24
47
  transformIndexHtml: {
25
48
  order: 'pre',
26
- handler(html) { return enabled ? { html, tags: [{ tag: 'script', attrs: { type: 'module', src: '/.terrarium/inject.ts' }, injectTo: 'body' }] } : html; },
49
+ handler(html) { return enabled && !react ? { html, tags: [{ tag: 'script', attrs: { type: 'module', src: '/.terrarium/inject.ts' }, injectTo: 'body' }] } : html; },
27
50
  },
28
51
  };
29
52
  }
@@ -31,3 +54,15 @@ export function terrarium(opts = {}) {
31
54
  /** the Worker entry: the user's scenario + the runtime (also used by the CLI's standalone build)
32
55
  * @param {string} scenarioImport */
33
56
  export const workerEntry = (scenarioImport) => `import scenario from '${scenarioImport}';\nimport { runScenario } from '@terrariumlabs/core/worker';\nrunScenario(scenario);\n`;
57
+
58
+ /** the generated React mount: a <Terrarium> over the generated Worker, with the plugin's devBar option baked in
59
+ * @param {boolean | 'hidden' | undefined} devBar */
60
+ export const reactMountEntry = (devBar) => `import { createElement, type ReactNode } from 'react';
61
+ import { Terrarium } from '@terrariumlabs/react';
62
+ export const enabled = true;
63
+ /** the Terrarium mounted from the React tree: render once at the root, with the app as its children (they render once
64
+ * the wallet is announced, so a wallet library that reconnects during its first render finds it) */
65
+ export default function TerrariumMount({ children }: { children?: ReactNode } = {}) {
66
+ return createElement(Terrarium, { worker: () => new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }), defer: true${devBar === undefined ? '' : `, devBar: ${JSON.stringify(devBar)}`} }, children);
67
+ }
68
+ `;
@@ -1,20 +1,37 @@
1
1
  // worker-runtime.ts — runs a scenario inside the Worker: boot the chain, run setup(), wire the actors, expose the
2
2
  // generic terrarium_* controls, and serve the provider to the page over postMessage.
3
- import { createPublicClient, createWalletClient, custom, defineChain, toHex, type Address, type Hex } from 'viem';
3
+ import { createPublicClient, createWalletClient, custom, defineChain, decodeErrorResult, decodeEventLog, decodeFunctionData, toEventSelector, toHex, type Abi, type Address, type Hex } from 'viem';
4
+ import { KNOWN_ABI } from './known-abi.ts';
4
5
  // @ts-ignore — the engine is plain ESM JavaScript
5
6
  import { createTerrarium, indexedDBStorage } from './engine.js';
6
7
  import { serveProvider } from './bridge.ts';
7
8
  import { runRoute, toWire } from './http.ts';
8
- import type { ScenarioConfig, ScenarioContext } from './scenario.ts';
9
+ import type { Actor, ScenarioConfig, ScenarioContext, ScenarioInput } from './scenario.ts';
9
10
 
10
- export async function runScenario(config: ScenarioConfig) {
11
+ type Storage = { getItem(k: string): Promise<any>; setItem(k: string, v: any): Promise<any>; removeItem(k: string): Promise<any>; clear(): Promise<any> };
12
+ const SELECTION_KEY = 'terrarium:scenario';
13
+ const slug = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') || 'scenario';
14
+ const nameOf = (s: ScenarioConfig, i: number) => s.name ?? `Scenario ${i + 1}`;
15
+
16
+ /** Boot one scenario, or the active one of a list (the dev bar's selector picks; the choice is stored so a reload keeps it).
17
+ * `opts.storage` replaces IndexedDB (tests). */
18
+ export async function runScenario(input: ScenarioInput, opts: { storage?: Storage } = {}) {
19
+ const list = Array.isArray(input) ? input : [input];
20
+ if (!list.length) throw new Error('runScenario: no scenario');
21
+ const names = list.map(nameOf);
22
+ const anyPersist = list.some((s) => s.persist !== false);
23
+ const store: Storage | null = opts.storage ?? (anyPersist ? indexedDBStorage('terrarium') : null);
24
+ const selected = list.length > 1 && store ? await store.getItem(SELECTION_KEY) : null;
25
+ const index = Math.max(0, names.indexOf(selected));
26
+ const config = list[index], scenarioName = names[index];
11
27
  const chainId = config.chainId ?? 31337;
12
28
  // the page learns what to intercept before the chain boots, so the dapp's first fetches are not held up by setup()
13
29
  const httpRoutes = toWire(config.http ?? []);
14
30
  if (typeof (globalThis as any).postMessage === 'function') (globalThis as any).postMessage({ event: 'httpRoutes', payload: httpRoutes });
15
31
  let httpHits = 0;
16
- const key = config.persist === false ? null : (config.persist ?? 'default');
17
- const storage = key ? indexedDBStorage('terrarium') : null;
32
+ // a listed scenario persists under its own slug unless it says otherwise, so switching back finds its chain again
33
+ const key = config.persist === false ? null : (config.persist ?? (list.length > 1 ? slug(scenarioName) : 'default'));
34
+ const storage = key ? store : null;
18
35
  const firstBoot = storage ? (await storage.getItem(key!)) === null : true;
19
36
  const restore = typeof config.restore === 'function' ? await config.restore() : config.restore;
20
37
  const bootWall = Math.floor(Date.now() / 1000);
@@ -24,6 +41,8 @@ export async function runScenario(config: ScenarioConfig) {
24
41
  const chain = defineChain({ id: chainId, name: 'Terrarium', nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, rpcUrls: { default: { http: [] } } });
25
42
  const pub = createPublicClient({ chain, transport: custom(sim.provider), pollingInterval: 20 });
26
43
  const rpc = (method: string, params: unknown[] = []) => sim.provider.request({ method, params });
44
+ // what the explorer knows about addresses: a name and/or an ABI, from ctx.label() and from install()'s fixture keys
45
+ const registry = new Map<string, { name?: string; auto?: boolean; abi?: Abi }>();
27
46
  const ctx: ScenarioContext = {
28
47
  sim, chainId, rpc, pub,
29
48
  accounts: sim.accounts.map((a: any) => a.address as Address),
@@ -34,34 +53,98 @@ export async function runScenario(config: ScenarioConfig) {
34
53
  fresh: sim.blockNumber === 0n,
35
54
  firstBoot,
36
55
  codeAt: async (a) => (await rpc('eth_getCode', [a, 'latest'])) as Hex,
37
- install: async (fixture) => { for (const c of Object.values(fixture.contracts)) if ((await ctx.codeAt(c.address as Address)) === '0x') await rpc('anvil_setCode', [c.address, c.code]); },
56
+ install: async (fixture: any) => {
57
+ // names and ABIs the fixture carries (import-anvil --broadcast/--artifacts, or written by hand): the explorer uses them
58
+ for (const [a, name] of Object.entries((fixture?.names ?? {}) as Record<string, string>)) { const k = a.toLowerCase(); registry.set(k, { ...registry.get(k), name, auto: true }); }
59
+ for (const [a, abi] of Object.entries((fixture?.abis ?? {}) as Record<string, Abi>)) { const k = a.toLowerCase(); registry.set(k, { ...registry.get(k), abi }); }
60
+ // an Anvil state dump (`terrarium import-anvil`, or anvil_dumpState by hand): whole accounts, code and storage,
61
+ // through anvil_loadState. Idempotent like the code fixtures: an account that already has code is left alone, and
62
+ // accounts without code (the deployer's nonce, funded EOAs) are only written on a fresh chain.
63
+ if (fixture?.accounts && !fixture.contracts) {
64
+ const accounts: Record<string, any> = {};
65
+ for (const [a, acct] of Object.entries(fixture.accounts as Record<string, any>)) {
66
+ if (!acct) continue;
67
+ const hasCode = acct.code && acct.code !== '0x';
68
+ if (hasCode ? (await ctx.codeAt(a as Address)) === '0x' : ctx.fresh) accounts[a] = acct;
69
+ }
70
+ if (Object.keys(accounts).length) await rpc('anvil_loadState', [{ accounts }]);
71
+ return;
72
+ }
73
+ for (const [key, c] of Object.entries(fixture.contracts) as [string, any][]) {
74
+ const k = c.address.toLowerCase(); if (!registry.get(k)?.name) registry.set(k, { ...registry.get(k), name: key, auto: true }); // the explorer names it by its fixture key
75
+ if ((await ctx.codeAt(c.address as Address)) === '0x') await rpc('anvil_setCode', [c.address, c.code]);
76
+ }
77
+ },
78
+ label: (address, name, abi) => { const k = address.toLowerCase(); registry.set(k, { ...registry.get(k), name, auto: false, ...(abi ? { abi } : {}) }); },
79
+ // the page reloads (a scenario that reset the chain, or rebuilt it, wants the dapp to start over on the new state)
80
+ reload: () => { if (typeof (globalThis as any).postMessage === 'function') (globalThis as any).postMessage({ event: 'reload', payload: null }); },
38
81
  state: {},
39
82
  };
40
83
  await config.setup?.(ctx);
41
84
  if (ctx.fresh && storage) await sim.flush();
42
85
 
43
- // ---- actors: toggled together, persisted, off by default ----------------------------------------------------
86
+ // ---- actors: toggled together, persisted, off by default; `always` actors run regardless (keepers the protocol needs) ---
87
+ const wire = (a: Actor) => {
88
+ const safe = (log?: any) => Promise.resolve().then(() => a.run(ctx, log)).catch((e) => console.warn(`[terrarium] actor ${a.name ?? ''} failed:`, e?.message ?? e));
89
+ const out: (() => void)[] = [];
90
+ if (a.every) { const t = setInterval(() => safe(), a.every); out.push(() => clearInterval(t)); }
91
+ if (a.on) out.push(sim.onLog(typeof a.on === 'function' ? a.on(ctx) : a.on, (log: any) => safe(log)));
92
+ return out;
93
+ };
94
+ const toggled = (config.actors ?? []).filter((a) => !a.always);
95
+ for (const a of (config.actors ?? []).filter((a) => a.always)) wire(a);
44
96
  const actorsKey = `${key}:actors`;
45
- let timers: ReturnType<typeof setInterval>[] = [], unsubs: (() => void)[] = [];
97
+ let unsubs: (() => void)[] = [];
46
98
  const actors = {
47
99
  enabled: storage ? (await storage.getItem(actorsKey)) === 'on' : false,
48
100
  async toggle(on: boolean) {
49
101
  actors.enabled = on; await storage?.setItem(actorsKey, on ? 'on' : 'off');
50
- timers.forEach(clearInterval); timers = []; unsubs.forEach((u) => u()); unsubs = [];
102
+ unsubs.forEach((u) => u()); unsubs = [];
51
103
  if (!on) return;
52
- for (const a of config.actors ?? []) {
53
- const safe = (log?: any) => Promise.resolve().then(() => a.run(ctx, log)).catch((e) => console.warn(`[terrarium] actor ${a.name ?? ''} failed:`, e?.message ?? e));
54
- if (a.every) timers.push(setInterval(() => safe(), a.every));
55
- if (a.on) unsubs.push(sim.onLog(typeof a.on === 'function' ? a.on(ctx) : a.on, (log: any) => safe(log)));
56
- }
104
+ for (const a of toggled) unsubs.push(...wire(a));
57
105
  },
58
106
  };
59
107
  if (actors.enabled) await actors.toggle(true);
60
108
 
61
109
  // ---- generic controls, reachable through the provider like any RPC method -----------------------------------
62
110
  sim.addMethod('terrarium_actors', async (on?: boolean) => { await actors.toggle(on ?? !actors.enabled); return actors.enabled; });
63
- sim.addMethod('terrarium_status', async () => ({ chainId, engine: sim.engine, block: toHex(sim.blockNumber), accounts: ctx.accounts, actors: actors.enabled, actorsLabel: config.actorsLabel ?? 'Actors', hasActors: (config.actors?.length ?? 0) > 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)) }));
64
- sim.addMethod('terrarium_reset', async () => { await actors.toggle(false); sim.stop(); await storage?.clear(); return true; });
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)) }));
112
+ // ---- the transaction explorer: the engine's list, decoded (address's own ABI, then `abis`, then the known set) and labelled
113
+ const configAbi = (config.abis ?? []).flat() as Abi;
114
+ const abisFor = (address: string | null): Abi[] => [registry.get((address ?? '').toLowerCase())?.abi, configAbi, KNOWN_ABI].filter((a): a is Abi => !!a && a.length > 0);
115
+ const labelsOf = () => {
116
+ const out: Record<string, string> = {};
117
+ ctx.accounts.forEach((a, i) => { out[a.toLowerCase()] = i === 0 ? 'You (#0)' : `Account #${i}`; }); // accounts[0] is the browser user
118
+ for (const [a, e] of registry) if (e.name && e.auto) out[a] = e.name; // fixture keys
119
+ for (const [a, name] of Object.entries(config.labels ?? {})) if (a) out[a.toLowerCase()] = name; // the config
120
+ for (const [a, e] of registry) if (e.name && !e.auto) out[a] = e.name; // ctx.label() wins
121
+ return out;
122
+ };
123
+ const plain = (v: any): any => typeof v === 'bigint' ? v.toString() : Array.isArray(v) ? v.map(plain) : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, plain(x)])) : v;
124
+ const first = <T,>(items: readonly any[], fn: (item: any) => T | null): T | null => { for (const item of items) { try { const r = fn(item); if (r) return r; } catch {} } return null; };
125
+ const decodeCall = (to: string, data: Hex) => first(abisFor(to), (abi: Abi) => { const d = decodeFunctionData({ abi, data }); return { name: d.functionName, args: plain(d.args ?? []) }; });
126
+ const decodeRevert = (to: string | null, data: Hex) => first(abisFor(to), (abi: Abi) => { const d = decodeErrorResult({ abi, data }); return { name: d.errorName, args: plain(d.args ?? []) }; });
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
+ 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
+ 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) => {
132
+ const hasData = t.input && t.input.length >= 10;
133
+ const method = !t.to ? { name: 'create', args: [] } : hasData ? decodeCall(t.to, t.input) ?? { name: null, selector: t.input.slice(0, 10) } : null;
134
+ const revert = t.status === 'reverted' && t.revertData && t.revertData !== '0x' ? decodeRevert(t.to, t.revertData) : null;
135
+ const logs = (t.receipt?.logs ?? []).map((l: any) => ({ ...l, decoded: decodeLog(l) }));
136
+ return { ...t, method, revert, logs };
137
+ }) };
138
+ });
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; });
141
+ // several scenarios: the list for the selector, and the switch (stored, then the page reloads and boots the chosen one)
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
+ sim.addMethod('terrarium_selectScenario', async (name: string) => {
144
+ if (!names.includes(name)) throw new Error(`no scenario named ${JSON.stringify(name)}; have: ${names.join(', ')}`);
145
+ if (name !== scenarioName) { await store?.setItem(SELECTION_KEY, name); await actors.toggle(false); sim.stop(); ctx.reload(); }
146
+ return name;
147
+ });
65
148
  for (const [name, fn] of Object.entries(config.methods ?? {})) sim.addMethod(name, (...args: any[]) => fn(ctx, ...args));
66
149
 
67
150
  // ---- HTTP routes: the page's fetch forwards matching requests here; the handler answers from the chain --------------