@terrariumlabs/core 0.4.0 → 0.6.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 +201 -72
- package/src/engine.js +35 -3
- 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 +90 -29
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.6.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.6.0"
|
|
64
65
|
},
|
|
65
66
|
"peerDependencies": {
|
|
66
67
|
"viem": "^2.0.0",
|
package/src/devbar.ts
CHANGED
|
@@ -1,39 +1,68 @@
|
|
|
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. Parts: the bar (grouped controls: scenario, chain,
|
|
3
|
+
// wallet, the scenario's own buttons, explorer / reset / hide), the transaction explorer (a panel above it listing every
|
|
4
|
+
// transaction with its receipt, decoded call, events and revert reason, fed by `terrarium_transactions`), the scenario
|
|
5
|
+
// selector (`terrarium_scenarios` / `terrarium_selectScenario`) when the Worker runs a list, toasts for every action, and
|
|
6
|
+
// two keyboard shortcuts (Alt+Shift+T: the bar, Alt+Shift+X: the explorer).
|
|
5
7
|
type Provider = { request(a: { method: string; params?: unknown[] }): Promise<any> };
|
|
6
8
|
|
|
7
9
|
const HIDDEN_KEY = 'terrarium:devbar-hidden';
|
|
8
10
|
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[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
|
|
11
|
+
#terrarium-devbar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 2147483000; display: flex; align-items: center; gap: 6px 14px; flex-wrap: wrap; padding: 8px 16px; background: #14231b; color: #dfe9e3; font: 12.5px/1.4 ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; font-variant-numeric: tabular-nums; box-shadow: 0 -1px 0 rgba(255,255,255,0.08); }
|
|
12
|
+
#terrarium-devbar[hidden], #terrarium-explorer[hidden], #terrarium-devbar-show[hidden], #terrarium-devbar-menu[hidden] { display: none; }
|
|
13
|
+
#terrarium-devbar *:focus-visible, #terrarium-explorer *:focus-visible, #terrarium-devbar-show:focus-visible { outline: 2px solid #e8c547; outline-offset: 1px; }
|
|
14
|
+
#terrarium-devbar .brand { display: flex; align-items: center; gap: 8px; }
|
|
15
|
+
#terrarium-devbar .tag { background: #e8c547; color: #14231b; font-weight: 700; padding: 2px 8px; border-radius: 6px; letter-spacing: 0.01em; }
|
|
16
|
+
#terrarium-devbar .name { color: #fff; font-weight: 600; }
|
|
17
|
+
#terrarium-devbar select { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.14); color: #fff; padding: 5px 8px; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer; max-width: 260px; }
|
|
18
|
+
#terrarium-devbar .status { display: flex; align-items: center; gap: 10px; color: rgba(223,233,227,0.72); white-space: nowrap; }
|
|
19
|
+
#terrarium-devbar .status b { color: #fff; font-weight: 600; }
|
|
20
|
+
#terrarium-devbar .status .warn { color: #e8c547; }
|
|
21
|
+
#terrarium-devbar .status .shift { color: #e8c547; font-weight: 600; }
|
|
22
|
+
#terrarium-devbar .spacer { flex: 1; min-width: 8px; }
|
|
23
|
+
#terrarium-devbar .group { display: flex; align-items: center; gap: 4px; padding-left: 10px; border-left: 1px solid rgba(255,255,255,0.12); position: relative; }
|
|
24
|
+
#terrarium-devbar .group[hidden] { display: none; }
|
|
25
|
+
#terrarium-devbar .glabel { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: rgba(223,233,227,0.45); margin-right: 4px; user-select: none; }
|
|
26
|
+
#terrarium-devbar .glabel:empty { display: none; }
|
|
27
|
+
@media (max-width: 1280px) { #terrarium-devbar .glabel { display: none; } #terrarium-devbar { gap: 6px 10px; padding: 6px 12px; } }
|
|
28
|
+
#terrarium-devbar button, #terrarium-devbar-menu button { background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.12); color: #fff; padding: 5px 9px; border-radius: 8px; font: inherit; cursor: pointer; white-space: nowrap; }
|
|
29
|
+
#terrarium-devbar button:hover { background: rgba(255,255,255,0.15); }
|
|
30
|
+
#terrarium-devbar button.on { background: #1f6f5c; border-color: #2b8a73; }
|
|
18
31
|
#terrarium-devbar button.armed { background: #7a3b2a; border-color: #b3452c; }
|
|
19
32
|
#terrarium-devbar button.danger { border-color: rgba(255,140,110,0.4); color: #ffb5a0; }
|
|
20
|
-
#terrarium-devbar button.
|
|
33
|
+
#terrarium-devbar button.danger.armed { background: #b3452c; border-color: #ff8c6e; color: #fff; }
|
|
34
|
+
#terrarium-devbar button.quiet { background: transparent; border-color: transparent; color: rgba(223,233,227,0.6); }
|
|
35
|
+
#terrarium-devbar button.quiet:hover { color: #fff; background: rgba(255,255,255,0.08); }
|
|
36
|
+
#terrarium-devbar button .badge { display: inline-block; margin-left: 6px; padding: 0 6px; border-radius: 999px; background: rgba(255,255,255,0.14); font-size: 11px; line-height: 16px; }
|
|
37
|
+
#terrarium-devbar button .badge.bad { background: #b3452c; }
|
|
38
|
+
#terrarium-devbar-menu { position: fixed; z-index: 2147483001; display: flex; flex-direction: column; gap: 2px; padding: 4px; background: #1b2e24; color: #dfe9e3; border: 1px solid rgba(255,255,255,0.14); border-radius: 10px; box-shadow: 0 6px 24px rgba(0,0,0,0.4); font: 12.5px/1.4 ui-sans-serif, system-ui, sans-serif; }
|
|
39
|
+
#terrarium-devbar-menu button { text-align: left; background: transparent; border-color: transparent; }
|
|
40
|
+
#terrarium-devbar-menu button:hover { background: rgba(255,255,255,0.1); }
|
|
21
41
|
#terrarium-devbar-show { position: fixed; right: 12px; bottom: 12px; z-index: 2147483000; width: 36px; height: 36px; border-radius: 50%; border: 1px solid rgba(255,255,255,0.2); background: #14231b; color: #e8c547; font: 16px/1 ui-sans-serif, system-ui, sans-serif; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.35); }
|
|
42
|
+
#terrarium-toast { position: fixed; right: 16px; z-index: 2147483001; display: flex; flex-direction: column; gap: 6px; align-items: flex-end; pointer-events: none; font: 12.5px/1.4 ui-sans-serif, system-ui, sans-serif; }
|
|
43
|
+
#terrarium-toast div { background: #1b2e24; color: #dfe9e3; border: 1px solid rgba(255,255,255,0.14); border-left: 3px solid #e8c547; border-radius: 8px; padding: 6px 10px; box-shadow: 0 4px 16px rgba(0,0,0,0.35); opacity: 1; transition: opacity 0.3s; max-width: 420px; }
|
|
44
|
+
#terrarium-toast div.bad { border-left-color: #ff8c6e; }
|
|
45
|
+
#terrarium-toast div.fade { opacity: 0; }
|
|
22
46
|
#terrarium-explorer { position: fixed; left: 0; right: 0; z-index: 2147483000; max-height: 60vh; overflow: auto; background: #0f1a14; color: #dfe9e3; border-top: 1px solid rgba(255,255,255,0.12); font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-variant-numeric: tabular-nums; }
|
|
23
|
-
#terrarium-explorer
|
|
47
|
+
#terrarium-explorer.tall { max-height: 90vh; }
|
|
48
|
+
#terrarium-explorer .head { display: flex; align-items: center; gap: 10px; padding: 8px 16px; font: 13px ui-sans-serif, system-ui, sans-serif; color: rgba(223,233,227,0.8); position: sticky; top: 0; background: #0f1a14; border-bottom: 1px solid rgba(255,255,255,0.08); }
|
|
24
49
|
#terrarium-explorer .head b { color: #fff; }
|
|
50
|
+
#terrarium-explorer .head select, #terrarium-explorer .head input, #terrarium-explorer .head button { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.14); color: #fff; padding: 3px 8px; border-radius: 6px; font: inherit; }
|
|
51
|
+
#terrarium-explorer .head input { width: 220px; } #terrarium-explorer .head input::placeholder { color: rgba(223,233,227,0.45); }
|
|
52
|
+
#terrarium-explorer .head button { cursor: pointer; } #terrarium-explorer .head button:hover { background: rgba(255,255,255,0.15); }
|
|
25
53
|
#terrarium-explorer table { width: 100%; border-collapse: collapse; }
|
|
26
54
|
#terrarium-explorer th { text-align: left; font-weight: 600; color: rgba(223,233,227,0.6); padding: 6px 12px; border-bottom: 1px solid rgba(255,255,255,0.08); white-space: nowrap; }
|
|
27
|
-
#terrarium-explorer th:first-child, #terrarium-explorer td:first-child { padding-left:
|
|
55
|
+
#terrarium-explorer th:first-child, #terrarium-explorer td:first-child { padding-left: 16px; }
|
|
28
56
|
#terrarium-explorer td { padding: 6px 12px; border-bottom: 1px solid rgba(255,255,255,0.05); white-space: nowrap; vertical-align: top; }
|
|
29
57
|
#terrarium-explorer tr.tx { cursor: pointer; }
|
|
30
58
|
#terrarium-explorer tr.tx:hover td { background: rgba(255,255,255,0.04); }
|
|
31
59
|
#terrarium-explorer tr.tx.open td { background: rgba(255,255,255,0.06); border-bottom-color: transparent; }
|
|
32
60
|
#terrarium-explorer .ok { color: #7fd3a8; } #terrarium-explorer .bad { color: #ff9c80; } #terrarium-explorer .wait { color: #e8c547; }
|
|
33
61
|
#terrarium-explorer .hash, #terrarium-explorer .addr { color: #9ecbff; }
|
|
62
|
+
#terrarium-explorer .hash { cursor: copy; } #terrarium-explorer .hash:hover { text-decoration: underline dotted; }
|
|
34
63
|
#terrarium-explorer .name { color: #e8c547; }
|
|
35
64
|
#terrarium-explorer .dim { color: rgba(223,233,227,0.55); }
|
|
36
|
-
#terrarium-explorer .detail td { padding: 10px
|
|
65
|
+
#terrarium-explorer .detail td { padding: 10px 16px 14px 40px; white-space: normal; background: rgba(0,0,0,0.25); }
|
|
37
66
|
#terrarium-explorer dl { display: grid; grid-template-columns: max-content 1fr; gap: 3px 16px; margin: 0; }
|
|
38
67
|
#terrarium-explorer dt { color: rgba(223,233,227,0.6); } #terrarium-explorer dd { margin: 0; word-break: break-all; }
|
|
39
68
|
#terrarium-explorer .events { margin: 10px 0 0; padding: 0; list-style: none; }
|
|
@@ -55,55 +84,102 @@ export const formatEth = (hex: string | null | undefined) => {
|
|
|
55
84
|
const formatGwei = (hex: string | null | undefined) => { if (!hex) return '0'; const wei = BigInt(hex), whole = wei / 10n ** 9n, frac = (wei % 10n ** 9n).toString().padStart(9, '0').slice(0, 3).replace(/0+$/, ''); return frac ? `${whole}.${frac}` : whole.toString(); };
|
|
56
85
|
const fmtArgs = (args: any): string => Array.isArray(args) ? args.map(fmtArgs).join(', ') : args && typeof args === 'object' ? Object.entries(args).map(([k, v]) => `${k}: ${fmtArgs(v)}`).join(', ') : String(args);
|
|
57
86
|
const time = (hex: string | null) => (hex ? new Date(Number(BigInt(hex)) * 1000).toLocaleTimeString() : '');
|
|
87
|
+
/** a duration in seconds as "+1h 30m" / "-2d" */
|
|
88
|
+
const span = (s: number) => { const a = Math.abs(s), sign = s < 0 ? '-' : '+'; if (a < 3600) return `${sign}${Math.round(a / 60)}m`; if (a < 86400) { const h = Math.floor(a / 3600), m = Math.round((a % 3600) / 60); return `${sign}${h}h${m ? ` ${m}m` : ''}`; } const d = Math.floor(a / 86400), h = Math.round((a % 86400) / 3600); return `${sign}${d}d${h ? ` ${h}h` : ''}`; };
|
|
58
89
|
|
|
59
|
-
export
|
|
90
|
+
export interface DevBarOptions {
|
|
91
|
+
/** start collapsed to the leaf (default false). A Hide/Show click is remembered in localStorage and overrides this */
|
|
92
|
+
hidden?: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function mountDevBar(provider: Provider, opts: DevBarOptions = {}) {
|
|
60
96
|
if (document.getElementById('terrarium-devbar')) return;
|
|
61
97
|
const rpc = (method: string, params: unknown[] = []) => provider.request({ method, params });
|
|
62
98
|
const bar = document.createElement('footer');
|
|
63
|
-
bar.id = 'terrarium-devbar'; bar.dataset.testid = 'devbar';
|
|
99
|
+
bar.id = 'terrarium-devbar'; bar.dataset.testid = 'devbar'; bar.setAttribute('role', 'toolbar'); bar.setAttribute('aria-label', 'Terrarium dev bar');
|
|
64
100
|
const style = document.createElement('style'); style.textContent = CSS;
|
|
65
101
|
bar.append(style);
|
|
66
102
|
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; };
|
|
103
|
+
const btn = (label: string, testid: string, title: string, onClick: () => Promise<unknown> | void) => { const b = el(`<button data-testid="${testid}" title="${esc(title)}" aria-label="${esc(title)}">${label}</button>`); b.onclick = () => Promise.resolve(onClick()).catch((e) => { console.warn('[terrarium]', e); toast(e?.message ?? String(e), true); }); return b; };
|
|
104
|
+
const group = (label: string, testid: string, ...children: HTMLElement[]) => { const g = el(`<div class="group" data-testid="${testid}" role="group" aria-label="${esc(label || 'tools')}"><span class="glabel">${label}</span></div>`); g.append(...children); return g; };
|
|
105
|
+
|
|
106
|
+
// ---- toasts: every action says what it did --------------------------------------------------------------------------------
|
|
107
|
+
const toasts = el('<div id="terrarium-toast" data-testid="toast" aria-live="polite"></div>');
|
|
108
|
+
const toast = (msg: string, bad = false) => {
|
|
109
|
+
const t = el(`<div${bad ? ' class="bad"' : ''}>${esc(msg)}</div>`); toasts.append(t);
|
|
110
|
+
setTimeout(() => { t.classList.add('fade'); setTimeout(() => t.remove(), 350); }, bad ? 5000 : 2200);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// ---- brand + scenario selector + status ---------------------------------------------------------------------------
|
|
114
|
+
const brand = el(`<span class="brand"><span class="tag">Terrarium</span><span class="name" data-testid="scenario-name" hidden></span></span>`);
|
|
115
|
+
const scenarioSelect = el(`<select data-testid="scenario" aria-label="Scenario" title="Which scenario the chain runs: switching stores the choice and reloads the page; each scenario keeps its own chain" hidden></select>`) as HTMLSelectElement;
|
|
116
|
+
brand.append(scenarioSelect);
|
|
117
|
+
let scenariosKey = '';
|
|
118
|
+
scenarioSelect.onchange = () => { toast(`Switching to “${scenarioSelect.value}”…`); rpc('terrarium_selectScenario', [scenarioSelect.value]).catch((e) => { console.warn('[terrarium]', e); toast(e?.message ?? String(e), true); }); };
|
|
119
|
+
const status = el(`<span class="status"><span>block <b data-testid="block" data-f="block">…</b></span><span>chain <span data-f="chain">…</span></span><span data-testid="clock" data-f="clock" title="The chain's clock: the timestamp the next block gets. Yellow when it has been shifted away from wall time (deadlines and oracles see this clock, not yours)"></span><span data-f="engine"></span></span>`);
|
|
68
120
|
|
|
69
|
-
|
|
70
|
-
let mining: 'auto' | 'interval' = 'auto', snap: string | null = null;
|
|
121
|
+
// ---- chain ------------------------------------------------------------------------------------------------------------
|
|
122
|
+
let mining: 'auto' | 'interval' = 'auto', snap: { id: string; block: number } | null = null, head = 0;
|
|
71
123
|
const bMining = btn('Blocks: instant', 'mining', 'Auto: a block per transaction. Interval: a block every 3s, so you can watch pending states', async () => {
|
|
72
124
|
mining = mining === 'auto' ? 'interval' : 'auto';
|
|
73
125
|
await (mining === 'auto' ? rpc('evm_setAutomine', [true]) : rpc('evm_setIntervalMining', [3000]));
|
|
74
|
-
bMining.textContent = mining === 'auto' ? 'Blocks: instant' : 'Blocks: every 3s';
|
|
126
|
+
bMining.textContent = mining === 'auto' ? 'Blocks: instant' : 'Blocks: every 3s'; bMining.classList.toggle('on', mining === 'interval');
|
|
127
|
+
toast(mining === 'auto' ? 'A block per transaction again' : 'A block every 3 seconds: transactions stay pending in between');
|
|
75
128
|
});
|
|
76
129
|
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 {
|
|
130
|
+
if (snap) { await rpc('evm_revert', [snap.id]); toast(`Reverted to block ${snap.block}`); snap = null; bSnap.textContent = 'Snapshot'; bSnap.classList.remove('on'); }
|
|
131
|
+
else { const id = await rpc('evm_snapshot'); snap = { id, block: head }; bSnap.textContent = `Revert to block ${head}`; bSnap.classList.add('on'); toast(`Snapshot at block ${head}: the next click reverts to it`); }
|
|
79
132
|
});
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
133
|
+
const timeMenu = el('<div id="terrarium-devbar-menu" data-testid="time-menu" role="menu" aria-label="Move the chain clock" hidden></div>'); // on the body: a menu inside the bar was clipped by its layout
|
|
134
|
+
const shift = (seconds: number, label: string, testid: string) => btn(label, testid, `Move the chain clock forward ${label.slice(1)} and mine a block`, async () => { timeMenu.hidden = true; await rpc('evm_increaseTime', [seconds]); await rpc('evm_mine'); toast(`Chain clock moved ${label}; a block sealed at the new time`); });
|
|
135
|
+
timeMenu.append(shift(60, '+1 minute', 'plus-minute'), shift(3600, '+1 hour', 'plus-hour'), shift(86400, '+1 day', 'plus-day'), shift(7 * 86400, '+1 week', 'plus-week'));
|
|
136
|
+
const bTime = btn('Time ▾', 'time', 'Move the chain clock forward: deadlines expire, oracles go stale, interest accrues', () => {
|
|
137
|
+
timeMenu.hidden = !timeMenu.hidden;
|
|
138
|
+
if (!timeMenu.hidden) { const r = bTime.getBoundingClientRect(); timeMenu.style.left = `${r.left}px`; timeMenu.style.bottom = `${window.innerHeight - r.top + 6}px`; }
|
|
139
|
+
});
|
|
140
|
+
bTime.setAttribute('aria-haspopup', 'menu');
|
|
141
|
+
const gChain = group('chain', 'group-chain',
|
|
142
|
+
btn('Mine a block', 'mine', 'Mine one empty block', async () => { await rpc('evm_mine'); toast(`Mined block ${head + 1}`); }),
|
|
143
|
+
bTime, bMining, bSnap);
|
|
144
|
+
document.addEventListener('click', (e) => { if (!timeMenu.hidden && !bTime.contains(e.target as Node) && !timeMenu.contains(e.target as Node)) timeMenu.hidden = true; });
|
|
145
|
+
|
|
146
|
+
// ---- wallet -----------------------------------------------------------------------------------------------------------
|
|
147
|
+
const bReject = btn('Reject next tx', 'reject-next', 'The wallet rejects the next signature request (EIP-1193 error 4001), like a user hitting Cancel', async () => { await rpc('terrarium_setWallet', [{ rejectNext: 1 }]); toast('The wallet will reject the next signature request'); });
|
|
148
|
+
const bLatency = btn('Wallet: instant', 'wallet-latency', 'Make the wallet take 2 seconds to answer, like a real one', async () => { const w = await rpc('terrarium_getWallet'); await rpc('terrarium_setWallet', [{ latencyMs: w.latencyMs ? 0 : 2000 }]); toast(w.latencyMs ? 'The wallet answers instantly again' : 'The wallet now takes 2 seconds to answer'); });
|
|
149
|
+
const bLag = btn('Receipts: instant', 'receipt-lag', 'Receipts appear 3 seconds after the block, like a node that has not caught up', async () => { const w = await rpc('terrarium_getWallet'); await rpc('terrarium_setWallet', [{ receiptLagMs: w.receiptLagMs ? 0 : 3000 }]); toast(w.receiptLagMs ? 'Receipts are immediate again' : 'Receipts now arrive 3 seconds after the block'); });
|
|
150
|
+
const gWallet = group('wallet', 'group-wallet', bReject, bLatency, bLag);
|
|
151
|
+
|
|
152
|
+
// ---- the scenario's own knobs: actors + controls ------------------------------------------------------------------------
|
|
153
|
+
const bActors = btn('Actors off', 'actors', 'Scripted actors: other users, keepers, arbitrageurs trading on their own', async () => { const on = await rpc('terrarium_actors'); toast(on ? `${actorsLabel} on: other actors are trading now` : `${actorsLabel} off`); });
|
|
154
|
+
let actorsLabel = 'Actors';
|
|
155
|
+
const controls = el('<span class="controls" style="display:contents"></span>'); let controlsKey = '';
|
|
156
|
+
const gScenario = group('scenario', 'group-scenario', bActors, controls);
|
|
86
157
|
|
|
87
|
-
// ---- the transaction explorer
|
|
88
|
-
const panel = el(`<section id="terrarium-explorer" data-testid="explorer" hidden></section>`);
|
|
158
|
+
// ---- the transaction explorer -----------------------------------------------------------------------------------------
|
|
159
|
+
const panel = el(`<section id="terrarium-explorer" data-testid="explorer" role="region" aria-label="Transactions" hidden></section>`);
|
|
89
160
|
const open = new Set<string>(); // expanded rows, by hash, kept across refreshes
|
|
90
|
-
let lastRender = '';
|
|
91
|
-
const bTxs = btn('Transactions', 'txs', 'Every transaction on this chain, like a block explorer: receipt, decoded call, events, revert reason',
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
});
|
|
95
|
-
const place = () => { panel.style.bottom = `${bar.offsetHeight}px`; };
|
|
161
|
+
let lastRender = '', filter: 'all' | 'mine' | 'failed' = 'all', search = '', me: string | null = null; // `me`: accounts[0] from terrarium_status
|
|
162
|
+
const bTxs = btn('Transactions', 'txs', 'Every transaction on this chain, like a block explorer: receipt, decoded call, events, revert reason (Alt+Shift+X)', () => togglePanel());
|
|
163
|
+
const togglePanel = async (show: boolean = !!panel.hidden) => { panel.hidden = !show; bTxs.classList.toggle('on', show); lastRender = ''; if (show) { await refreshTxs(); panel.querySelector<HTMLInputElement>('[data-testid=tx-search]')?.focus(); } place(); };
|
|
164
|
+
const place = () => { panel.style.bottom = `${bar.offsetHeight}px`; toasts.style.bottom = `${bar.offsetHeight + (panel.hidden ? 0 : panel.offsetHeight) + 12}px`; }; // toasts sit above whatever is open
|
|
96
165
|
const statusCell = (s: string) => s === 'success' ? '<span class="ok" title="success">✓</span>' : s === 'pending' ? '<span class="wait" title="pending">⏳</span>' : `<span class="bad" title="${s}">✗</span>`;
|
|
97
166
|
const who = (labels: Record<string, string>, a: string | null) => a ? `<span class="addr" title="${a}">${esc(labels[a.toLowerCase()] ?? short(a))}</span>` : '<span class="dim">contract creation</span>';
|
|
167
|
+
const matches = (t: any, L: Record<string, string>) => {
|
|
168
|
+
if (!search) return true;
|
|
169
|
+
const q = search.toLowerCase();
|
|
170
|
+
const hay = [t.hash, t.from, t.to, L[t.from?.toLowerCase()], L[t.to?.toLowerCase()], t.method?.name, t.method?.selector, t.revert?.name, t.status, ...(t.logs ?? []).flatMap((l: any) => [l.decoded?.name, L[l.address?.toLowerCase()], l.address])].filter(Boolean).join(' ').toLowerCase();
|
|
171
|
+
return hay.includes(q);
|
|
172
|
+
};
|
|
98
173
|
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]]);
|
|
174
|
+
const key = JSON.stringify([data.total, data.transactions.map((t) => [t.hash, t.status]), [...open], filter, search, me]);
|
|
100
175
|
if (key === lastRender) return; lastRender = key;
|
|
101
176
|
const L = data.labels ?? {};
|
|
102
|
-
const
|
|
177
|
+
const shown = data.transactions.filter((t) => (filter === 'all' ? true : filter === 'failed' ? t.status !== 'success' && t.status !== 'pending' : !!me && t.from?.toLowerCase() === me.toLowerCase()) && matches(t, L));
|
|
178
|
+
const rows = shown.map((t) => {
|
|
103
179
|
const method = t.method ? (t.method.name ? `<span class="name">${esc(t.method.name)}</span>` : `<span class="dim">${esc(t.method.selector)}</span>`) : '<span class="dim">transfer</span>';
|
|
104
180
|
const isOpen = open.has(t.hash);
|
|
105
181
|
const detail = !isOpen ? '' : `<tr class="detail" data-testid="tx-detail"><td colspan="10"><dl>
|
|
106
|
-
<dt>hash</dt><dd class="hash">${esc(t.hash)}</dd>
|
|
182
|
+
<dt>hash</dt><dd class="hash" data-copy="${t.hash}" title="click to copy">${esc(t.hash)}</dd>
|
|
107
183
|
<dt>status</dt><dd>${esc(t.status)}${t.status === 'reverted' || t.status === 'dropped' ? ` <span class="bad">· ${t.revert ? esc(t.revert.name) + '(' + esc(fmtArgs(t.revert.args)) + ')' : esc(t.error ?? '')}</span>` : ''}</dd>
|
|
108
184
|
<dt>block</dt><dd>${t.receipt ? `${num(t.receipt.blockNumber)} · ${esc(time(t.timestamp))} · index ${num(t.receipt.transactionIndex)}` : 'pending'}</dd>
|
|
109
185
|
<dt>from</dt><dd>${who(L, t.from)} <span class="dim">${esc(t.from)}</span></dd>
|
|
@@ -118,63 +194,116 @@ export function mountDevBar(provider: Provider) {
|
|
|
118
194
|
</td></tr>`;
|
|
119
195
|
return `<tr class="tx${isOpen ? ' open' : ''}" data-testid="tx-row" data-hash="${t.hash}" data-status="${t.status}">
|
|
120
196
|
<td>${statusCell(t.status)}</td><td>${t.receipt ? num(t.receipt.blockNumber) : '<span class="dim">pending</span>'}</td><td class="dim">${esc(time(t.timestamp))}</td>
|
|
121
|
-
<td class="hash" title="${t.hash}">${short(t.hash)}</td><td>${method}</td><td>${who(L, t.from)}</td><td>${who(L, t.to)}</td>
|
|
197
|
+
<td class="hash" data-copy="${t.hash}" title="${t.hash} (click to copy)">${short(t.hash)}</td><td>${method}</td><td>${who(L, t.from)}</td><td>${who(L, t.to)}</td>
|
|
122
198
|
<td>${formatEth(t.value)} ETH</td><td class="dim">${t.receipt ? num(t.receipt.gasUsed) : ''}</td><td class="dim">${t.logs?.length ? `${t.logs.length} event${t.logs.length === 1 ? '' : 's'}` : ''}</td></tr>${detail}`;
|
|
123
199
|
}).join('');
|
|
124
|
-
|
|
125
|
-
|
|
200
|
+
const headEl = panel.querySelector('.head');
|
|
201
|
+
const headHtml = `<b>Transactions</b> <span data-testid="explorer-count">${data.total} on this chain${data.total > data.transactions.length ? ` (showing ${data.transactions.length})` : ''}${filter !== 'all' || search ? `, ${shown.length} shown` : ''}</span>
|
|
202
|
+
<input data-testid="tx-search" type="search" placeholder="filter: hash, address, name, method, event…" aria-label="Filter transactions" value="${esc(search)}">
|
|
203
|
+
<select data-testid="tx-filter" aria-label="Which transactions" title="Which transactions to list"><option value="all"${filter === 'all' ? ' selected' : ''}>all</option><option value="mine"${filter === 'mine' ? ' selected' : ''}>mine (account #0)</option><option value="failed"${filter === 'failed' ? ' selected' : ''}>failed</option></select>
|
|
204
|
+
<span class="spacer" style="flex:1"></span><span class="dim">click a row for the receipt, the decoded call and the events · Esc closes</span>
|
|
205
|
+
<button data-testid="explorer-size" title="Taller / shorter panel">${panel.classList.contains('tall') ? '⇩ shorter' : '⇧ taller'}</button>`;
|
|
206
|
+
const body = rows ? `<table><thead><tr><th></th><th>block</th><th>time</th><th>hash</th><th>method</th><th>from</th><th>to</th><th>value</th><th>gas used</th><th>events</th></tr></thead><tbody>${rows}</tbody></table>` : `<div class="empty">${data.total ? 'Nothing matches.' : 'No transactions yet. Do something in the dapp and it appears here.'}</div>`;
|
|
207
|
+
if (headEl && document.activeElement === headEl.querySelector('[data-testid=tx-search]')) {
|
|
208
|
+
// keep the focused search box: update its neighbours only
|
|
209
|
+
headEl.querySelector('[data-testid=explorer-count]')!.textContent = `${data.total} on this chain${filter !== 'all' || search ? `, ${shown.length} shown` : ''}`;
|
|
210
|
+
panel.querySelector('table, .empty')?.remove(); panel.insertAdjacentHTML('beforeend', body);
|
|
211
|
+
} else panel.innerHTML = `<div class="head">${headHtml}</div>${body}`;
|
|
126
212
|
place();
|
|
127
213
|
};
|
|
214
|
+
panel.addEventListener('change', (e) => { const sel = e.target as HTMLSelectElement; if (sel.dataset.testid === 'tx-filter') { filter = sel.value as typeof filter; lastRender = ''; refreshTxs(); } });
|
|
215
|
+
panel.addEventListener('input', (e) => { const inp = e.target as HTMLInputElement; if (inp.dataset.testid === 'tx-search') { search = inp.value.trim(); lastRender = ''; refreshTxs(); } });
|
|
128
216
|
panel.addEventListener('click', (e) => {
|
|
129
|
-
const
|
|
217
|
+
const target = e.target as HTMLElement;
|
|
218
|
+
const copy = target.closest<HTMLElement>('[data-copy]');
|
|
219
|
+
if (copy) { e.stopPropagation(); navigator.clipboard?.writeText(copy.dataset.copy!).then(() => toast(`Copied ${short(copy.dataset.copy!)}`), () => toast('Clipboard unavailable', true)); return; }
|
|
220
|
+
if (target.closest('[data-testid=explorer-size]')) { panel.classList.toggle('tall'); lastRender = ''; refreshTxs(); return; }
|
|
221
|
+
const row = target.closest<HTMLElement>('tr.tx'); if (!row) return;
|
|
130
222
|
const h = row.dataset.hash!; open.has(h) ? open.delete(h) : open.add(h); lastRender = ''; refreshTxs();
|
|
131
223
|
});
|
|
132
224
|
const refreshTxs = async () => { const data = await rpc('terrarium_transactions', [{ limit: 200 }]).catch(() => null); if (data) render(data); };
|
|
133
225
|
|
|
134
|
-
// ---- hide / show
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
226
|
+
// ---- reset (two clicks), hide / show ------------------------------------------------------------------------------------
|
|
227
|
+
let resetArmed: ReturnType<typeof setTimeout> | null = null;
|
|
228
|
+
const bReset = btn('Reset', 'reset', 'Wipe this scenario\'s chain and boot it again from scratch (click twice)', async () => {
|
|
229
|
+
if (!resetArmed) { bReset.textContent = 'Really reset?'; bReset.classList.add('armed'); toast('Click Reset again within 3 seconds to wipe this scenario\'s chain'); resetArmed = setTimeout(() => { resetArmed = null; bReset.textContent = 'Reset'; bReset.classList.remove('armed'); }, 3000); return; }
|
|
230
|
+
clearTimeout(resetArmed); resetArmed = null; bReset.textContent = 'Resetting…';
|
|
231
|
+
await rpc('terrarium_reset'); location.reload();
|
|
232
|
+
});
|
|
233
|
+
bReset.classList.add('danger');
|
|
234
|
+
const pill = el(`<button id="terrarium-devbar-show" data-testid="show" title="Show the Terrarium dev bar (Alt+Shift+T)" aria-label="Show the Terrarium dev bar" hidden>🌱</button>`);
|
|
235
|
+
const remember = (hidden: boolean) => { try { localStorage.setItem(HIDDEN_KEY, hidden ? '1' : '0'); } catch {} };
|
|
236
|
+
const apply = (hidden: boolean) => { bar.hidden = hidden; pill.hidden = !hidden; if (hidden) { panel.hidden = true; bTxs.classList.remove('on'); } document.body.style.paddingBottom = hidden ? '' : '56px'; toasts.style.bottom = hidden ? '60px' : `${(bar.offsetHeight || 48) + 12}px`; };
|
|
237
|
+
const setHidden = (hidden: boolean) => { apply(hidden); remember(hidden); };
|
|
238
|
+
const bHide = btn('Hide', 'hide', 'Hide the dev bar (the chain keeps running); the leaf at the bottom right or Alt+Shift+T brings it back', () => setHidden(true));
|
|
144
239
|
bHide.classList.add('quiet');
|
|
145
240
|
pill.onclick = () => setHidden(false);
|
|
241
|
+
const gTools = group('', 'group-tools', bTxs, bReset, bHide);
|
|
146
242
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
bMining, bSnap, bActors, bReject, bLatency, bLag, bTxs, bReset, bHide, panel);
|
|
152
|
-
document.body.append(bar, pill);
|
|
153
|
-
let startHidden = false; try { startHidden = localStorage.getItem(HIDDEN_KEY) === '1'; } catch {}
|
|
154
|
-
setHidden(startHidden);
|
|
243
|
+
bar.append(brand, status, el('<span class="spacer"></span>'), gChain, gWallet, gScenario, gTools, panel);
|
|
244
|
+
document.body.append(bar, pill, toasts, timeMenu);
|
|
245
|
+
let startHidden = !!opts.hidden; try { const v = localStorage.getItem(HIDDEN_KEY); if (v !== null) startHidden = v === '1'; } catch {}
|
|
246
|
+
apply(startHidden); // without remembering: the default is not a choice
|
|
155
247
|
|
|
248
|
+
// ---- keyboard: Alt+Shift+T the bar, Alt+Shift+X the explorer, Esc closes the explorer / the time menu -----------------------------
|
|
249
|
+
const onKey = (e: KeyboardEvent) => {
|
|
250
|
+
if (!document.getElementById('terrarium-devbar')) { document.removeEventListener('keydown', onKey); return; }
|
|
251
|
+
if (e.altKey && e.shiftKey && (e.code === 'KeyT')) { e.preventDefault(); setHidden(!bar.hidden); }
|
|
252
|
+
else if (e.altKey && e.shiftKey && (e.code === 'KeyX')) { e.preventDefault(); if (bar.hidden) setHidden(false); togglePanel(); }
|
|
253
|
+
else if (e.key === 'Escape') { if (!panel.hidden) togglePanel(false); timeMenu.hidden = true; }
|
|
254
|
+
};
|
|
255
|
+
document.addEventListener('keydown', onKey);
|
|
256
|
+
|
|
257
|
+
// ---- polling: status every 500 ms, the explorer while open, the scenario list now and then -----------------------------------
|
|
258
|
+
const refreshScenarios = async () => {
|
|
259
|
+
const s = await rpc('terrarium_scenarios').catch(() => null); if (!s) return;
|
|
260
|
+
const k = JSON.stringify(s); if (k === scenariosKey) return; scenariosKey = k;
|
|
261
|
+
const many = s.scenarios.length > 1;
|
|
262
|
+
scenarioSelect.hidden = !many;
|
|
263
|
+
const name = brand.querySelector<HTMLElement>('[data-testid=scenario-name]')!;
|
|
264
|
+
name.hidden = many || !s.active || /^Scenario \d+$/.test(s.active); name.textContent = s.active ?? '';
|
|
265
|
+
if (many) {
|
|
266
|
+
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; }));
|
|
267
|
+
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.`;
|
|
268
|
+
}
|
|
269
|
+
};
|
|
156
270
|
const refresh = async () => {
|
|
157
|
-
if (!document.getElementById('terrarium-devbar')) return; // unmounted: stop polling
|
|
158
271
|
const s = await rpc('terrarium_status').catch(() => null); if (!s) return;
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
272
|
+
me = s.accounts?.[0] ?? me; head = parseInt(s.block, 16); actorsLabel = s.actorsLabel ?? actorsLabel;
|
|
273
|
+
status.querySelector('[data-f=chain]')!.textContent = String(s.chainId);
|
|
274
|
+
status.querySelector('[data-f=block]')!.textContent = String(head);
|
|
275
|
+
if (s.now) {
|
|
276
|
+
const chainNow = Number(BigInt(s.now)), drift = chainNow - Math.floor(Date.now() / 1000);
|
|
277
|
+
const clock = status.querySelector<HTMLElement>('[data-f=clock]')!;
|
|
278
|
+
clock.innerHTML = Math.abs(drift) > 90 ? `clock ${esc(new Date(chainNow * 1000).toLocaleString())} <span class="shift">${esc(span(drift))}</span>` : `clock ${esc(new Date(chainNow * 1000).toLocaleTimeString())}`;
|
|
279
|
+
}
|
|
280
|
+
const notes: string[] = [];
|
|
281
|
+
if (s.fork) notes.push(`fork @${s.fork.blockNumber}${s.fork.offline ? ' offline' : ''}${s.fork.misses ? ` · <span class="warn">${s.fork.misses} MISSES</span>` : ''}`);
|
|
282
|
+
if (s.http?.routes) notes.push(`${s.http.routes} HTTP route${s.http.routes === 1 ? '' : 's'}, ${s.http.hits} answered`);
|
|
283
|
+
if (s.restoredFromPersistence) notes.push(`${s.localBlocks} block${s.localBlocks === 1 ? '' : 's'} restored`);
|
|
284
|
+
status.querySelector('[data-f=engine]')!.innerHTML = notes.map((n) => `<span>${n}</span>`).join('');
|
|
285
|
+
status.title = `revm/wasm · the chain persists in IndexedDB${s.restoredFromPersistence ? ' (Reset to start clean)' : ''}`;
|
|
286
|
+
if (s.txs) { const bad = s.txs.failed ? `<span class="badge bad" title="${s.txs.failed} failed">${s.txs.failed} ✗</span>` : ''; const pend = s.txs.pending ? `<span class="badge" title="${s.txs.pending} pending">${s.txs.pending} ⏳</span>` : ''; bTxs.innerHTML = `Transactions<span class="badge">${s.txs.total}</span>${pend}${bad}`; }
|
|
164
287
|
const ck = JSON.stringify(s.controls ?? []);
|
|
165
|
-
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 ?? [])))); }
|
|
288
|
+
if (ck !== controlsKey) { controlsKey = ck; controls.replaceChildren(...(s.controls ?? []).map((c: any, i: number) => btn(c.label, `control-${i}`, c.title ?? c.method, async () => { const r = await rpc(c.method, c.params ?? []); toast(`${c.label}${r !== undefined && r !== null && typeof r !== 'object' ? ` → ${r}` : ''}`); }))); }
|
|
166
289
|
bActors.hidden = !s.hasActors; bActors.textContent = `${s.actorsLabel} ${s.actors ? 'on' : 'off'}`; bActors.classList.toggle('on', s.actors);
|
|
290
|
+
gScenario.hidden = !s.hasActors && !(s.controls?.length);
|
|
167
291
|
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
292
|
bLatency.textContent = s.wallet.latencyMs ? `Wallet: ${s.wallet.latencyMs / 1000}s delay` : 'Wallet: instant'; bLatency.classList.toggle('on', !!s.wallet.latencyMs);
|
|
169
293
|
bLag.textContent = s.wallet.receiptLagMs ? `Receipts: ${s.wallet.receiptLagMs / 1000}s late` : 'Receipts: instant'; bLag.classList.toggle('on', !!s.wallet.receiptLagMs);
|
|
294
|
+
if (!bar.hidden) place();
|
|
170
295
|
if (!panel.hidden) await refreshTxs();
|
|
171
296
|
};
|
|
172
|
-
refresh();
|
|
297
|
+
refresh(); refreshScenarios();
|
|
298
|
+
let ticks = 0;
|
|
299
|
+
const timer = setInterval(() => { if (!document.getElementById('terrarium-devbar')) return clearInterval(timer); refresh(); if (++ticks % 10 === 0) refreshScenarios(); }, 500);
|
|
173
300
|
}
|
|
174
301
|
|
|
175
|
-
/** Remove the dev bar, its explorer and the show pill; restore the page's bottom padding. Idempotent. */
|
|
302
|
+
/** Remove the dev bar, its explorer, the toasts and the show pill; restore the page's bottom padding. Idempotent. */
|
|
176
303
|
export function unmountDevBar() {
|
|
177
304
|
document.getElementById('terrarium-devbar')?.remove();
|
|
178
305
|
document.getElementById('terrarium-devbar-show')?.remove();
|
|
306
|
+
document.getElementById('terrarium-toast')?.remove();
|
|
307
|
+
document.getElementById('terrarium-devbar-menu')?.remove();
|
|
179
308
|
document.body?.style.removeProperty('padding-bottom');
|
|
180
309
|
}
|
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 */
|
|
@@ -458,7 +458,7 @@ export async function createTerrarium(opts = {}) {
|
|
|
458
458
|
return { ...t.rpc, status, receipt: t.receipt, timestamp: b ? hex(b.timestamp) : null, error: t.error ?? null, revertData: t.revertData ?? null };
|
|
459
459
|
});
|
|
460
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)) };
|
|
461
|
+
return { total: all.length, pending: all.filter((t) => t.status === 'pending').length, failed: all.filter((t) => t.status === 'reverted' || t.status === 'dropped').length, transactions: all.slice(from, from + Math.max(0, limit)) };
|
|
462
462
|
}
|
|
463
463
|
|
|
464
464
|
// ---- RPC formatting ----------------------------------------------------------------------------
|
|
@@ -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) });
|
|
@@ -778,7 +810,7 @@ export async function createTerrarium(opts = {}) {
|
|
|
778
810
|
dumpState: () => exclusive(dumpState), loadState: (d) => exclusive(() => loadState(d)), replayJournal: (j) => exclusive(() => replayJournal(j)),
|
|
779
811
|
get journal() { return journal.slice(); }, flush: () => { clearTimeout(persistTimer); return persister ? exclusive(async () => persister.setItem(persistKey, JSON.stringify(await dumpState()))) : Promise.resolve(); },
|
|
780
812
|
get blockNumber() { return latest().number; },
|
|
781
|
-
/** transactions newest first ({ total, transactions }): tx + receipt + status word + revert reason/data + block timestamp */
|
|
813
|
+
/** transactions newest first ({ total, pending, failed, transactions }): tx + receipt + status word + revert reason/data + block timestamp */
|
|
782
814
|
transactions: (o) => listTransactions(o),
|
|
783
815
|
/** React to on-chain events with scripted actors (keepers, oracles, other users, bridges...). */
|
|
784
816
|
onLog(filter, handler) { const l = { filter, handler }; logListeners.push(l); return () => logListeners.splice(logListeners.indexOf(l), 1); },
|
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), now: toHex(sim.now()), txs: (({ total, pending, failed }) => ({ total, pending, failed }))(sim.transactions({ limit: 0 })), accounts: ctx.accounts, actors: actors.enabled, actorsLabel: config.actorsLabel ?? 'Actors', hasActors: toggled.length > 0, wallet: { ...sim.wallet }, controls: config.controls ?? [], restoredFromPersistence: sim.restoredFromPersistence, localBlocks: Number(sim.blockNumber) - (config.fork ? config.fork.blockNumber + 1 : 0), http: { routes: httpRoutes.length, hits: httpHits }, fork: config.fork ? { blockNumber: config.fork.blockNumber, offline: !!config.fork.offline, misses: sim.offlineMisses.length } : null, ...(await config.status?.(ctx)) }));
|
|
112
|
+
// ---- 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
|
-
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 }; });
|
|
130
|
+
const { total, pending, failed, transactions } = sim.transactions(opts ?? {});
|
|
131
|
+
return { total, pending, failed, 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 --------------
|