@terrariumlabs/core 0.4.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 +59 -4
- package/package.json +3 -2
- package/src/devbar.ts +104 -48
- package/src/engine.js +33 -1
- package/src/inject.ts +6 -3
- package/src/known-abi.ts +79 -0
- package/src/scenario.ts +39 -8
- package/src/transport.ts +47 -0
- package/src/vite-plugin.d.ts +25 -2
- package/src/vite-plugin.js +40 -5
- package/src/worker-runtime.ts +89 -28
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
|
+
"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",
|
|
@@ -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.
|
|
64
|
+
"@terrariumlabs/evm": "^0.5.0"
|
|
64
65
|
},
|
|
65
66
|
"peerDependencies": {
|
|
66
67
|
"viem": "^2.0.0",
|
package/src/devbar.ts
CHANGED
|
@@ -1,30 +1,41 @@
|
|
|
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.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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.
|
|
5
6
|
type Provider = { request(a: { method: string; params?: unknown[] }): Promise<any> };
|
|
6
7
|
|
|
7
8
|
const HIDDEN_KEY = 'terrarium:devbar-hidden';
|
|
8
9
|
const CSS = `
|
|
9
|
-
#terrarium-devbar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 2147483000; display: flex; align-items: center; gap:
|
|
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); }
|
|
10
11
|
#terrarium-devbar[hidden], #terrarium-explorer[hidden], #terrarium-devbar-show[hidden] { display: none; }
|
|
11
|
-
#terrarium-devbar .
|
|
12
|
-
#terrarium-devbar .
|
|
13
|
-
#terrarium-devbar .
|
|
14
|
-
#terrarium-devbar
|
|
15
|
-
#terrarium-devbar
|
|
16
|
-
#terrarium-devbar
|
|
17
|
-
#terrarium-devbar
|
|
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; }
|
|
18
27
|
#terrarium-devbar button.armed { background: #7a3b2a; border-color: #b3452c; }
|
|
19
28
|
#terrarium-devbar button.danger { border-color: rgba(255,140,110,0.4); color: #ffb5a0; }
|
|
20
|
-
#terrarium-devbar button.quiet { background: transparent; border-color: transparent; color: rgba(223,233,227,0.
|
|
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); }
|
|
21
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); }
|
|
22
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; }
|
|
23
|
-
#terrarium-explorer .head { display: flex; align-items: center; gap: 12px; padding: 8px
|
|
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); }
|
|
24
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; }
|
|
25
36
|
#terrarium-explorer table { width: 100%; border-collapse: collapse; }
|
|
26
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; }
|
|
27
|
-
#terrarium-explorer th:first-child, #terrarium-explorer td:first-child { padding-left:
|
|
38
|
+
#terrarium-explorer th:first-child, #terrarium-explorer td:first-child { padding-left: 16px; }
|
|
28
39
|
#terrarium-explorer td { padding: 6px 12px; border-bottom: 1px solid rgba(255,255,255,0.05); white-space: nowrap; vertical-align: top; }
|
|
29
40
|
#terrarium-explorer tr.tx { cursor: pointer; }
|
|
30
41
|
#terrarium-explorer tr.tx:hover td { background: rgba(255,255,255,0.04); }
|
|
@@ -33,7 +44,7 @@ const CSS = `
|
|
|
33
44
|
#terrarium-explorer .hash, #terrarium-explorer .addr { color: #9ecbff; }
|
|
34
45
|
#terrarium-explorer .name { color: #e8c547; }
|
|
35
46
|
#terrarium-explorer .dim { color: rgba(223,233,227,0.55); }
|
|
36
|
-
#terrarium-explorer .detail td { padding: 10px
|
|
47
|
+
#terrarium-explorer .detail td { padding: 10px 16px 14px 40px; white-space: normal; background: rgba(0,0,0,0.25); }
|
|
37
48
|
#terrarium-explorer dl { display: grid; grid-template-columns: max-content 1fr; gap: 3px 16px; margin: 0; }
|
|
38
49
|
#terrarium-explorer dt { color: rgba(223,233,227,0.6); } #terrarium-explorer dd { margin: 0; word-break: break-all; }
|
|
39
50
|
#terrarium-explorer .events { margin: 10px 0 0; padding: 0; list-style: none; }
|
|
@@ -56,7 +67,12 @@ const formatGwei = (hex: string | null | undefined) => { if (!hex) return '0'; c
|
|
|
56
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);
|
|
57
68
|
const time = (hex: string | null) => (hex ? new Date(Number(BigInt(hex)) * 1000).toLocaleTimeString() : '');
|
|
58
69
|
|
|
59
|
-
export
|
|
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 = {}) {
|
|
60
76
|
if (document.getElementById('terrarium-devbar')) return;
|
|
61
77
|
const rpc = (method: string, params: unknown[] = []) => provider.request({ method, params });
|
|
62
78
|
const bar = document.createElement('footer');
|
|
@@ -64,30 +80,48 @@ export function mountDevBar(provider: Provider) {
|
|
|
64
80
|
const style = document.createElement('style'); style.textContent = CSS;
|
|
65
81
|
bar.append(style);
|
|
66
82
|
const el = (html: string) => { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild as HTMLElement; };
|
|
67
|
-
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; };
|
|
85
|
+
|
|
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>`);
|
|
68
93
|
|
|
69
|
-
|
|
94
|
+
// ---- chain ------------------------------------------------------------------------------------------------------------
|
|
70
95
|
let mining: 'auto' | 'interval' = 'auto', snap: string | null = null;
|
|
71
96
|
const bMining = btn('Blocks: instant', 'mining', 'Auto: a block per transaction. Interval: a block every 3s, so you can watch pending states', async () => {
|
|
72
97
|
mining = mining === 'auto' ? 'interval' : 'auto';
|
|
73
98
|
await (mining === 'auto' ? rpc('evm_setAutomine', [true]) : rpc('evm_setIntervalMining', [3000]));
|
|
74
|
-
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');
|
|
75
100
|
});
|
|
76
101
|
const bSnap = btn('Snapshot', 'snapshot', 'Snapshot the chain; revert brings blocks, receipts, journal and the UI history back', async () => {
|
|
77
|
-
if (snap) { await rpc('evm_revert', [snap]); snap = null; bSnap.textContent = 'Snapshot'; }
|
|
78
|
-
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'); }
|
|
79
104
|
});
|
|
80
|
-
const
|
|
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 -----------------------------------------------------------------------------------------------------------
|
|
81
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 }]));
|
|
82
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 }]); });
|
|
83
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 }]); });
|
|
84
|
-
const
|
|
85
|
-
bReset.classList.add('danger');
|
|
114
|
+
const gWallet = group('wallet', 'group-wallet', bReject, bLatency, bLag);
|
|
86
115
|
|
|
87
|
-
// ---- the
|
|
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'));
|
|
118
|
+
const controls = el('<span class="controls" style="display:contents"></span>'); let controlsKey = '';
|
|
119
|
+
const gScenario = group('scenario', 'group-scenario', bActors, controls);
|
|
120
|
+
|
|
121
|
+
// ---- the transaction explorer -----------------------------------------------------------------------------------------
|
|
88
122
|
const panel = el(`<section id="terrarium-explorer" data-testid="explorer" hidden></section>`);
|
|
89
123
|
const open = new Set<string>(); // expanded rows, by hash, kept across refreshes
|
|
90
|
-
let lastRender = '';
|
|
124
|
+
let lastRender = '', filter: 'all' | 'mine' | 'failed' = 'all', me: string | null = null; // `me`: accounts[0] from terrarium_status
|
|
91
125
|
const bTxs = btn('Transactions', 'txs', 'Every transaction on this chain, like a block explorer: receipt, decoded call, events, revert reason', async () => {
|
|
92
126
|
panel.hidden = !panel.hidden; bTxs.classList.toggle('on', !panel.hidden); lastRender = '';
|
|
93
127
|
if (!panel.hidden) await refreshTxs();
|
|
@@ -96,10 +130,11 @@ export function mountDevBar(provider: Provider) {
|
|
|
96
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>`;
|
|
97
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>';
|
|
98
132
|
const render = (data: { total: number; labels: Record<string, string>; transactions: any[] }) => {
|
|
99
|
-
const key = JSON.stringify([data.total, data.transactions.map((t) => [t.hash, t.status]), [...open]]);
|
|
133
|
+
const key = JSON.stringify([data.total, data.transactions.map((t) => [t.hash, t.status]), [...open], filter, me]);
|
|
100
134
|
if (key === lastRender) return; lastRender = key;
|
|
101
135
|
const L = data.labels ?? {};
|
|
102
|
-
const
|
|
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) => {
|
|
103
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>';
|
|
104
139
|
const isOpen = open.has(t.hash);
|
|
105
140
|
const detail = !isOpen ? '' : `<tr class="detail" data-testid="tx-detail"><td colspan="10"><dl>
|
|
@@ -121,55 +156,76 @@ export function mountDevBar(provider: Provider) {
|
|
|
121
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>
|
|
122
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}`;
|
|
123
158
|
}).join('');
|
|
124
|
-
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})` : ''}
|
|
125
|
-
|
|
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>`);
|
|
126
163
|
place();
|
|
127
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(); } });
|
|
128
166
|
panel.addEventListener('click', (e) => {
|
|
129
167
|
const row = (e.target as HTMLElement).closest<HTMLElement>('tr.tx'); if (!row) return;
|
|
130
168
|
const h = row.dataset.hash!; open.has(h) ? open.delete(h) : open.add(h); lastRender = ''; refreshTxs();
|
|
131
169
|
});
|
|
132
170
|
const refreshTxs = async () => { const data = await rpc('terrarium_transactions', [{ limit: 200 }]).catch(() => null); if (data) render(data); };
|
|
133
171
|
|
|
134
|
-
// ---- hide / show
|
|
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');
|
|
135
175
|
const pill = el(`<button id="terrarium-devbar-show" data-testid="show" title="Show the Terrarium dev bar" hidden>🌱</button>`);
|
|
136
|
-
const remember = (hidden: boolean) => { try {
|
|
176
|
+
const remember = (hidden: boolean) => { try { localStorage.setItem(HIDDEN_KEY, hidden ? '1' : '0'); } catch {} };
|
|
137
177
|
const setHidden = (hidden: boolean) => {
|
|
138
178
|
bar.hidden = hidden; pill.hidden = !hidden;
|
|
139
179
|
if (hidden) { panel.hidden = true; bTxs.classList.remove('on'); }
|
|
140
|
-
document.body.style.paddingBottom = hidden ? '' : '
|
|
180
|
+
document.body.style.paddingBottom = hidden ? '' : '56px';
|
|
141
181
|
remember(hidden);
|
|
142
182
|
};
|
|
143
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));
|
|
144
184
|
bHide.classList.add('quiet');
|
|
145
185
|
pill.onclick = () => setHidden(false);
|
|
186
|
+
const gTools = group('', 'group-tools', bTxs, bReset, bHide);
|
|
146
187
|
|
|
147
|
-
|
|
148
|
-
bar.append(el('<span class="tag">Terrarium</span>'), info, el('<span class="spacer"></span>'), controls,
|
|
149
|
-
btn('Mine a block', 'mine', 'Mine one empty block', () => rpc('evm_mine')),
|
|
150
|
-
btn('+1 hour', 'plus-hour', 'Move the chain clock forward one hour', async () => { await rpc('evm_increaseTime', [3600]); await rpc('evm_mine'); }),
|
|
151
|
-
bMining, bSnap, bActors, bReject, bLatency, bLag, bTxs, bReset, bHide, panel);
|
|
188
|
+
bar.append(brand, status, el('<span class="spacer"></span>'), gChain, gWallet, gScenario, gTools, panel);
|
|
152
189
|
document.body.append(bar, pill);
|
|
153
|
-
let startHidden =
|
|
154
|
-
|
|
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
|
|
155
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
|
+
};
|
|
156
206
|
const refresh = async () => {
|
|
157
|
-
if (!document.getElementById('terrarium-devbar')) return; // unmounted: stop polling
|
|
158
207
|
const s = await rpc('terrarium_status').catch(() => null); if (!s) return;
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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)' : ''}`;
|
|
164
217
|
const ck = JSON.stringify(s.controls ?? []);
|
|
165
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 ?? [])))); }
|
|
166
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);
|
|
167
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);
|
|
168
222
|
bLatency.textContent = s.wallet.latencyMs ? `Wallet: ${s.wallet.latencyMs / 1000}s delay` : 'Wallet: instant'; bLatency.classList.toggle('on', !!s.wallet.latencyMs);
|
|
169
223
|
bLag.textContent = s.wallet.receiptLagMs ? `Receipts: ${s.wallet.receiptLagMs / 1000}s late` : 'Receipts: instant'; bLag.classList.toggle('on', !!s.wallet.receiptLagMs);
|
|
170
|
-
if (!panel.hidden) await refreshTxs();
|
|
224
|
+
if (!panel.hidden) { await refreshTxs(); place(); }
|
|
171
225
|
};
|
|
172
|
-
refresh();
|
|
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);
|
|
173
229
|
}
|
|
174
230
|
|
|
175
231
|
/** Remove the dev bar, its explorer and the show pill; restore the page's bottom padding. Idempotent. */
|
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 */
|
|
@@ -583,6 +583,7 @@ export async function createTerrarium(opts = {}) {
|
|
|
583
583
|
case 'anvil_setCode': case 'hardhat_setCode': await sm.putCode(createAddressFromString(params[0]), hexToBytes(params[1])); return null;
|
|
584
584
|
case 'anvil_setNonce': case 'hardhat_setNonce': await sm.modifyAccountFields(createAddressFromString(params[0]), { nonce: hexToBigInt(params[1]) }); return null;
|
|
585
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]);
|
|
586
587
|
case 'anvil_impersonateAccount': case 'hardhat_impersonateAccount': impersonated.add(params[0].toLowerCase()); return null;
|
|
587
588
|
case 'anvil_stopImpersonatingAccount': case 'hardhat_stopImpersonatingAccount': impersonated.delete(params[0].toLowerCase()); return null;
|
|
588
589
|
case 'anvil_setNextBlockBaseFeePerGas': case 'hardhat_setNextBlockBaseFeePerGas': baseFee = hexToBigInt(params[0]); return null;
|
|
@@ -709,6 +710,37 @@ export async function createTerrarium(opts = {}) {
|
|
|
709
710
|
return written;
|
|
710
711
|
}
|
|
711
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
|
+
|
|
712
744
|
// ---- persistence: dump the diff, restore it, or replay the journal -------------------------------
|
|
713
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) });
|
|
714
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) });
|
package/src/inject.ts
CHANGED
|
@@ -8,8 +8,9 @@ import { installHttpInterceptor, type WireRoute } from './http.ts';
|
|
|
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);
|
|
12
|
-
|
|
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
|
|
package/src/known-abi.ts
ADDED
|
@@ -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
|
@@ -12,7 +12,10 @@ 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
|
|
40
|
-
|
|
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,12 +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[];
|
|
92
|
-
/** ABIs
|
|
93
|
-
*
|
|
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 */
|
|
94
120
|
abis?: Abi[];
|
|
95
|
-
/** names for addresses
|
|
96
|
-
*
|
|
97
|
-
labels?: Record<string, string
|
|
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>;
|
|
98
124
|
}
|
|
99
125
|
|
|
100
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[];
|
package/src/transport.ts
ADDED
|
@@ -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
|
+
}
|
package/src/vite-plugin.d.ts
CHANGED
|
@@ -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
|
|
9
|
-
*
|
|
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
|
+
}
|
package/src/vite-plugin.js
CHANGED
|
@@ -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
|
-
/**
|
|
8
|
-
|
|
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
|
-
|
|
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
|
+
`;
|
package/src/worker-runtime.ts
CHANGED
|
@@ -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, decodeErrorResult, decodeEventLog, decodeFunctionData, toHex, type Abi, 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
|
-
|
|
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
|
-
|
|
17
|
-
const
|
|
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,56 +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) => {
|
|
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
|
|
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
|
-
|
|
102
|
+
unsubs.forEach((u) => u()); unsubs = [];
|
|
51
103
|
if (!on) return;
|
|
52
|
-
for (const a of
|
|
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:
|
|
64
|
-
// ---- the transaction explorer: the engine's list, decoded
|
|
65
|
-
const
|
|
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);
|
|
66
115
|
const labelsOf = () => {
|
|
67
116
|
const out: Record<string, string> = {};
|
|
68
|
-
ctx.accounts.forEach((a, i) => { out[a.toLowerCase()] = `Account #${i}`; });
|
|
69
|
-
const
|
|
70
|
-
for (const [a, name] of Object.entries(
|
|
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
|
|
71
121
|
return out;
|
|
72
122
|
};
|
|
73
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;
|
|
74
|
-
const
|
|
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 ?? {}) }; }));
|
|
75
129
|
sim.addMethod('terrarium_transactions', (opts?: { limit?: number; before?: string }) => {
|
|
76
130
|
const { total, transactions } = sim.transactions(opts ?? {});
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
const logs = (t.receipt?.logs ?? []).map((l: any) => { const d = abi.length ? attempt(() => decodeEventLog({ abi, data: l.data, topics: l.topics })) : null; return { ...l, decoded: d ? { name: d.eventName, args: plain(d.args ?? {}) } : null }; });
|
|
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) }));
|
|
83
136
|
return { ...t, method, revert, logs };
|
|
84
137
|
}) };
|
|
85
138
|
});
|
|
86
|
-
|
|
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
|
+
});
|
|
87
148
|
for (const [name, fn] of Object.entries(config.methods ?? {})) sim.addMethod(name, (...args: any[]) => fn(ctx, ...args));
|
|
88
149
|
|
|
89
150
|
// ---- HTTP routes: the page's fetch forwards matching requests here; the handler answers from the chain --------------
|