@terrariumlabs/core 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/terrarium.mjs +59 -4
- package/package.json +4 -3
- package/src/devbar.ts +197 -30
- package/src/engine.js +52 -3
- package/src/inject.ts +8 -6
- package/src/known-abi.ts +79 -0
- package/src/scenario.ts +41 -4
- 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 +99 -16
package/bin/terrarium.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// terrarium — the CLI.
|
|
3
3
|
//
|
|
4
|
-
// terrarium build [--scenario terrarium.scenario.ts] [--out dist-terrarium]
|
|
4
|
+
// terrarium build [--scenario terrarium.scenario.ts] [--out dist-terrarium] [--devbar hidden|off]
|
|
5
5
|
// One injectable classic script (dist-terrarium/terrarium.js): chain Worker + wallet + dev bar. Inject it into
|
|
6
6
|
// any page (Playwright addInitScript, a <script> tag, a bookmarklet) — your dapp built without the Terrarium,
|
|
7
7
|
// a Storybook, someone else's dapp.
|
|
@@ -15,6 +15,12 @@
|
|
|
15
15
|
// account (balance, nonce, code) and storage slot, runs your script against the fork (every account, code blob
|
|
16
16
|
// and slot the EVM touches is recorded), rolls the script's changes back (unless --keep) and dumps. The fixture
|
|
17
17
|
// is what a scenario's `fork: { blockNumber, offline: true }, restore: fixture.dump` consumes.
|
|
18
|
+
//
|
|
19
|
+
// terrarium import-anvil [name=0xaddress]... --rpc <url> [--broadcast run-latest.json] [--artifacts out/] [--out fixture.json] [--skip 0xaddress]...
|
|
20
|
+
// Everything a running Anvil holds (anvil_dumpState: every account's code, storage, nonce and balance) as a fixture
|
|
21
|
+
// for ctx.install(): deploy your protocol with its own tooling (forge script, hardhat deploy) against Anvil and
|
|
22
|
+
// boot the Terrarium from the result, byte for byte. --skip leaves accounts out (Anvil's funded test accounts,
|
|
23
|
+
// which the Terrarium funds itself, are skipped by default).
|
|
18
24
|
import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
19
25
|
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
20
26
|
import { pathToFileURL } from 'node:url';
|
|
@@ -28,9 +34,12 @@ for (let i = 0; i < rest.length; i++) {
|
|
|
28
34
|
}
|
|
29
35
|
const define = { 'process.env.DEBUG': 'undefined', 'process.env.TERRARIUM_DEBUG': 'undefined' };
|
|
30
36
|
const usage = `usage:
|
|
31
|
-
terrarium build [--scenario terrarium.scenario.ts] [--out dist-terrarium]
|
|
37
|
+
terrarium build [--scenario terrarium.scenario.ts] [--out dist-terrarium] [--devbar hidden|off]
|
|
32
38
|
terrarium fetch-code <name=0xaddress>... --rpc <url> [--block N] [--chain ID] [--out fixture.json]
|
|
33
|
-
terrarium record [name=0xaddress]... --rpc <url> [--block N] [--chain ID] [--storage name:slot,slot] [--script warm.mjs] [--keep] [--out fixture.json]
|
|
39
|
+
terrarium record [name=0xaddress]... --rpc <url> [--block N] [--chain ID] [--storage name:slot,slot] [--script warm.mjs] [--keep] [--out fixture.json]
|
|
40
|
+
terrarium import-anvil [name=0xaddress]... --rpc <url> [--broadcast run-latest.json] [--artifacts out/] [--out fixture.json] [--skip 0xaddress]...
|
|
41
|
+
--broadcast: a Foundry broadcast (contract names + addresses), --artifacts: the out/ (or artifacts/) dir with their ABIs;
|
|
42
|
+
both land in the fixture as names/abis, so the dev bar's explorer shows the deployment decoded with no scenario code`;
|
|
34
43
|
const fail = (msg) => { console.error(msg); process.exit(1); };
|
|
35
44
|
const hex = (n) => '0x' + BigInt(n).toString(16);
|
|
36
45
|
/** a raw JSON-RPC call to --rpc */
|
|
@@ -53,7 +62,7 @@ if (cmd === 'build') {
|
|
|
53
62
|
const scenario = '/' + relative(root, resolve(root, args.scenario ?? 'terrarium.scenario.ts')).split(sep).join('/');
|
|
54
63
|
const dir = resolve(root, '.terrarium'); mkdirSync(dir, { recursive: true });
|
|
55
64
|
writeFileSync(join(dir, 'worker.ts'), `import scenario from '${scenario}';\nimport { runScenario } from '@terrariumlabs/core/worker';\nrunScenario(scenario);\n`);
|
|
56
|
-
writeFileSync(join(dir, 'inject-bundle.ts'), `import { startTerrarium } from '@terrariumlabs/core/inject';\ndeclare const __TERRARIUM_WORKER_SRC__: string;\nstartTerrarium(new Worker(URL.createObjectURL(new Blob([__TERRARIUM_WORKER_SRC__], { type: 'text/javascript' })), { type: 'module' }));\n`);
|
|
65
|
+
writeFileSync(join(dir, 'inject-bundle.ts'), `import { startTerrarium } from '@terrariumlabs/core/inject';\ndeclare const __TERRARIUM_WORKER_SRC__: string;\nstartTerrarium(new Worker(URL.createObjectURL(new Blob([__TERRARIUM_WORKER_SRC__], { type: 'text/javascript' })), { type: 'module' })${args.devbar === 'hidden' ? ", { devBar: 'hidden' }" : args.devbar === 'off' ? ', { devBar: false }' : ''});\n`);
|
|
57
66
|
await build({ root, configFile: false, logLevel: 'warn', define, build: { outDir: out, emptyOutDir: true, target: 'es2022', minify: true, lib: { entry: '.terrarium/worker.ts', formats: ['es'], fileName: () => 'terrarium.worker.js' }, rollupOptions: { output: { codeSplitting: false } } } });
|
|
58
67
|
const outDir = resolve(root, out);
|
|
59
68
|
const workerSrc = readFileSync(join(outDir, 'terrarium.worker.js'), 'utf8');
|
|
@@ -74,6 +83,52 @@ if (cmd === 'build') {
|
|
|
74
83
|
const out = args.out ?? 'fixture.json';
|
|
75
84
|
writeFileSync(out, JSON.stringify(fixture, null, 2));
|
|
76
85
|
console.log(`wrote ${out} (chain ${chainId}, block ${blockNumber})`);
|
|
86
|
+
} else if (cmd === 'import-anvil') {
|
|
87
|
+
if (!args.rpc) fail(usage);
|
|
88
|
+
const { gunzipSync } = await import('node:zlib');
|
|
89
|
+
const { TEST_KEYS } = await import('../src/engine.js');
|
|
90
|
+
const { privateKeyToAccount } = await import('viem/accounts');
|
|
91
|
+
const chainId = await resolveChain();
|
|
92
|
+
const blockNumber = Number(await remote('eth_blockNumber'));
|
|
93
|
+
const raw = await remote('anvil_dumpState');
|
|
94
|
+
const bytes = Buffer.from(raw.slice(2), 'hex');
|
|
95
|
+
const dump = JSON.parse((bytes[0] === 0x1f && bytes[1] === 0x8b ? gunzipSync(bytes) : bytes).toString('utf8'));
|
|
96
|
+
// Anvil's ten test accounts are the Terrarium's own (funded at genesis): only their nonces matter, so a deployer's
|
|
97
|
+
// next transaction does not collide with what it already deployed
|
|
98
|
+
const testAccounts = new Set(TEST_KEYS.map((k) => privateKeyToAccount(k).address.toLowerCase()));
|
|
99
|
+
const skip = new Set([].concat(args.skip ?? []).map((a) => String(a).toLowerCase()));
|
|
100
|
+
const accounts = {}; let slots = 0, contracts = 0;
|
|
101
|
+
for (const [address, acct] of Object.entries(dump.accounts ?? {})) {
|
|
102
|
+
const a = address.toLowerCase(); if (skip.has(a) || !acct) continue;
|
|
103
|
+
const code = acct.code && acct.code !== '0x' ? acct.code : undefined;
|
|
104
|
+
const storage = acct.storage && Object.keys(acct.storage).length ? acct.storage : undefined;
|
|
105
|
+
if (testAccounts.has(a)) { if (Number(acct.nonce)) accounts[a] = { nonce: Number(acct.nonce) }; continue; }
|
|
106
|
+
accounts[a] = { nonce: Number(acct.nonce), balance: acct.balance, code, storage };
|
|
107
|
+
if (code) contracts++; slots += Object.keys(storage ?? {}).length;
|
|
108
|
+
}
|
|
109
|
+
// names: name=0xaddress positionals, and a Foundry broadcast (forge script --broadcast writes broadcast/<Script>/<chain>/run-latest.json)
|
|
110
|
+
const names = {};
|
|
111
|
+
for (const p of positional) { const [name, address] = p.split('='); if (name && address?.startsWith('0x')) names[address.toLowerCase()] = name; }
|
|
112
|
+
if (args.broadcast) {
|
|
113
|
+
const b = JSON.parse(readFileSync(resolve(process.cwd(), args.broadcast), 'utf8'));
|
|
114
|
+
for (const tx of b.transactions ?? []) {
|
|
115
|
+
if (tx.contractName && tx.contractAddress) names[tx.contractAddress.toLowerCase()] ??= tx.contractName;
|
|
116
|
+
for (const c of tx.additionalContracts ?? []) if (c.address) names[c.address.toLowerCase()] ??= c.contractName ?? (tx.contractName ? `${tx.contractName} (created)` : 'contract');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// ABIs: for each named contract, <name>.json somewhere under --artifacts (Foundry out/<File>.sol/<Name>.json, Hardhat artifacts/…)
|
|
120
|
+
const abis = {};
|
|
121
|
+
if (args.artifacts) {
|
|
122
|
+
const { readdirSync } = await import('node:fs');
|
|
123
|
+
const files = new Map();
|
|
124
|
+
const walk = (dir) => { for (const e of readdirSync(dir, { withFileTypes: true })) { const p = join(dir, e.name); if (e.isDirectory()) walk(p); else if (e.name.endsWith('.json') && !e.name.endsWith('.dbg.json')) files.set(e.name.slice(0, -5), p); } };
|
|
125
|
+
walk(resolve(process.cwd(), args.artifacts));
|
|
126
|
+
for (const [address, name] of Object.entries(names)) { const p = files.get(name); if (!p) continue; const abi = JSON.parse(readFileSync(p, 'utf8')).abi; if (Array.isArray(abi)) abis[address] = abi; }
|
|
127
|
+
}
|
|
128
|
+
const fixture = { source: `Anvil state imported with \`terrarium import-anvil\` via ${args.rpc}`, chainId, blockNumber, importedAt: new Date().toISOString(), accounts, ...(Object.keys(names).length ? { names } : {}), ...(Object.keys(abis).length ? { abis } : {}) };
|
|
129
|
+
const out = args.out ?? 'fixture.json';
|
|
130
|
+
writeFileSync(out, JSON.stringify(fixture));
|
|
131
|
+
console.log(`wrote ${out}: ${contracts} contracts, ${Object.keys(accounts).length} accounts, ${slots} storage slots (chain ${chainId}, block ${blockNumber})${Object.keys(names).length ? `, ${Object.keys(names).length} named, ${Object.keys(abis).length} with an ABI` : ''}`);
|
|
77
132
|
} else if (cmd === 'record') {
|
|
78
133
|
if (!args.rpc) fail(usage);
|
|
79
134
|
const [{ createTerrarium }, viem] = await Promise.all([import('../src/engine.js'), import('viem')]);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@terrariumlabs/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "A complete EVM chain inside the page, presented to your dapp as a wallet. revm in WebAssembly, real receipts, byte-identical to Anvil.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"access": "public"
|
|
34
34
|
},
|
|
35
35
|
"bin": {
|
|
36
|
-
"terrarium": "
|
|
36
|
+
"terrarium": "bin/terrarium.mjs"
|
|
37
37
|
},
|
|
38
38
|
"exports": {
|
|
39
39
|
".": "./src/engine.js",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"./devbar": "./src/devbar.ts",
|
|
45
45
|
"./bridge": "./src/bridge.ts",
|
|
46
46
|
"./http": "./src/http.ts",
|
|
47
|
+
"./transport": "./src/transport.ts",
|
|
47
48
|
"./vite": "./src/vite-plugin.js",
|
|
48
49
|
"./fixtures/*": "./fixtures/*"
|
|
49
50
|
},
|
|
@@ -60,7 +61,7 @@
|
|
|
60
61
|
"@ethereumjs/util": "^10.1.3",
|
|
61
62
|
"@ethereumjs/mpt": "^10.1.3",
|
|
62
63
|
"@ethereumjs/rlp": "^10.0.0",
|
|
63
|
-
"@terrariumlabs/evm": "^0.
|
|
64
|
+
"@terrariumlabs/evm": "^0.5.0"
|
|
64
65
|
},
|
|
65
66
|
"peerDependencies": {
|
|
66
67
|
"viem": "^2.0.0",
|
package/src/devbar.ts
CHANGED
|
@@ -1,21 +1,78 @@
|
|
|
1
1
|
// devbar.ts — the dev overlay. Plain DOM, own styles, talks to the chain only through provider.request(), so it works
|
|
2
|
-
// on top of any dapp (React or not) and never touches the dapp's code.
|
|
2
|
+
// on top of any dapp (React or not) and never touches the dapp's code. Three parts: the bar (grouped controls: scenario,
|
|
3
|
+
// chain, wallet, the scenario's own buttons, explorer / reset / hide), the transaction explorer (a panel above it listing
|
|
4
|
+
// every transaction with its receipt, decoded call, events and revert reason, fed by `terrarium_transactions`), and the
|
|
5
|
+
// scenario selector (`terrarium_scenarios` / `terrarium_selectScenario`) when the Worker runs a list of scenarios.
|
|
3
6
|
type Provider = { request(a: { method: string; params?: unknown[] }): Promise<any> };
|
|
4
7
|
|
|
8
|
+
const HIDDEN_KEY = 'terrarium:devbar-hidden';
|
|
5
9
|
const CSS = `
|
|
6
|
-
#terrarium-devbar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 2147483000; display: flex; align-items: center; gap:
|
|
7
|
-
#terrarium-devbar
|
|
8
|
-
#terrarium-devbar .
|
|
9
|
-
#terrarium-devbar .
|
|
10
|
-
#terrarium-devbar .
|
|
11
|
-
#terrarium-devbar
|
|
12
|
-
#terrarium-devbar
|
|
13
|
-
#terrarium-devbar
|
|
10
|
+
#terrarium-devbar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 2147483000; display: flex; align-items: center; gap: 6px 14px; flex-wrap: wrap; padding: 8px 16px; background: #14231b; color: #dfe9e3; font: 12.5px/1.4 ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; font-variant-numeric: tabular-nums; box-shadow: 0 -1px 0 rgba(255,255,255,0.08); }
|
|
11
|
+
#terrarium-devbar[hidden], #terrarium-explorer[hidden], #terrarium-devbar-show[hidden] { display: none; }
|
|
12
|
+
#terrarium-devbar .brand { display: flex; align-items: center; gap: 8px; }
|
|
13
|
+
#terrarium-devbar .tag { background: #e8c547; color: #14231b; font-weight: 700; padding: 2px 8px; border-radius: 6px; letter-spacing: 0.01em; }
|
|
14
|
+
#terrarium-devbar .name { color: #fff; font-weight: 600; }
|
|
15
|
+
#terrarium-devbar select { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.14); color: #fff; padding: 5px 8px; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer; max-width: 260px; }
|
|
16
|
+
#terrarium-devbar .status { color: rgba(223,233,227,0.72); white-space: nowrap; }
|
|
17
|
+
#terrarium-devbar .status b { color: #fff; font-weight: 600; }
|
|
18
|
+
#terrarium-devbar .status .warn { color: #e8c547; }
|
|
19
|
+
#terrarium-devbar .spacer { flex: 1; min-width: 8px; }
|
|
20
|
+
#terrarium-devbar .group { display: flex; align-items: center; gap: 4px; padding-left: 10px; border-left: 1px solid rgba(255,255,255,0.12); }
|
|
21
|
+
#terrarium-devbar .group[hidden] { display: none; }
|
|
22
|
+
#terrarium-devbar .glabel { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: rgba(223,233,227,0.45); margin-right: 4px; user-select: none; }
|
|
23
|
+
#terrarium-devbar .glabel:empty { display: none; }
|
|
24
|
+
#terrarium-devbar button { background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.12); color: #fff; padding: 5px 9px; border-radius: 8px; font: inherit; cursor: pointer; white-space: nowrap; }
|
|
25
|
+
#terrarium-devbar button:hover { background: rgba(255,255,255,0.15); }
|
|
26
|
+
#terrarium-devbar button.on { background: #1f6f5c; border-color: #2b8a73; }
|
|
14
27
|
#terrarium-devbar button.armed { background: #7a3b2a; border-color: #b3452c; }
|
|
15
28
|
#terrarium-devbar button.danger { border-color: rgba(255,140,110,0.4); color: #ffb5a0; }
|
|
29
|
+
#terrarium-devbar button.quiet { background: transparent; border-color: transparent; color: rgba(223,233,227,0.6); }
|
|
30
|
+
#terrarium-devbar button.quiet:hover { color: #fff; background: rgba(255,255,255,0.08); }
|
|
31
|
+
#terrarium-devbar-show { position: fixed; right: 12px; bottom: 12px; z-index: 2147483000; width: 36px; height: 36px; border-radius: 50%; border: 1px solid rgba(255,255,255,0.2); background: #14231b; color: #e8c547; font: 16px/1 ui-sans-serif, system-ui, sans-serif; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.35); }
|
|
32
|
+
#terrarium-explorer { position: fixed; left: 0; right: 0; z-index: 2147483000; max-height: 60vh; overflow: auto; background: #0f1a14; color: #dfe9e3; border-top: 1px solid rgba(255,255,255,0.12); font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-variant-numeric: tabular-nums; }
|
|
33
|
+
#terrarium-explorer .head { display: flex; align-items: center; gap: 12px; padding: 8px 16px; font: 13px ui-sans-serif, system-ui, sans-serif; color: rgba(223,233,227,0.8); position: sticky; top: 0; background: #0f1a14; border-bottom: 1px solid rgba(255,255,255,0.08); }
|
|
34
|
+
#terrarium-explorer .head b { color: #fff; }
|
|
35
|
+
#terrarium-explorer .head select { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.14); color: #fff; padding: 3px 6px; border-radius: 6px; font: inherit; }
|
|
36
|
+
#terrarium-explorer table { width: 100%; border-collapse: collapse; }
|
|
37
|
+
#terrarium-explorer th { text-align: left; font-weight: 600; color: rgba(223,233,227,0.6); padding: 6px 12px; border-bottom: 1px solid rgba(255,255,255,0.08); white-space: nowrap; }
|
|
38
|
+
#terrarium-explorer th:first-child, #terrarium-explorer td:first-child { padding-left: 16px; }
|
|
39
|
+
#terrarium-explorer td { padding: 6px 12px; border-bottom: 1px solid rgba(255,255,255,0.05); white-space: nowrap; vertical-align: top; }
|
|
40
|
+
#terrarium-explorer tr.tx { cursor: pointer; }
|
|
41
|
+
#terrarium-explorer tr.tx:hover td { background: rgba(255,255,255,0.04); }
|
|
42
|
+
#terrarium-explorer tr.tx.open td { background: rgba(255,255,255,0.06); border-bottom-color: transparent; }
|
|
43
|
+
#terrarium-explorer .ok { color: #7fd3a8; } #terrarium-explorer .bad { color: #ff9c80; } #terrarium-explorer .wait { color: #e8c547; }
|
|
44
|
+
#terrarium-explorer .hash, #terrarium-explorer .addr { color: #9ecbff; }
|
|
45
|
+
#terrarium-explorer .name { color: #e8c547; }
|
|
46
|
+
#terrarium-explorer .dim { color: rgba(223,233,227,0.55); }
|
|
47
|
+
#terrarium-explorer .detail td { padding: 10px 16px 14px 40px; white-space: normal; background: rgba(0,0,0,0.25); }
|
|
48
|
+
#terrarium-explorer dl { display: grid; grid-template-columns: max-content 1fr; gap: 3px 16px; margin: 0; }
|
|
49
|
+
#terrarium-explorer dt { color: rgba(223,233,227,0.6); } #terrarium-explorer dd { margin: 0; word-break: break-all; }
|
|
50
|
+
#terrarium-explorer .events { margin: 10px 0 0; padding: 0; list-style: none; }
|
|
51
|
+
#terrarium-explorer .events li { padding: 6px 0 6px 10px; border-left: 2px solid rgba(232,197,71,0.5); margin-bottom: 4px; word-break: break-all; }
|
|
52
|
+
#terrarium-explorer .args { color: #dfe9e3; }
|
|
53
|
+
#terrarium-explorer .empty { padding: 24px; text-align: center; color: rgba(223,233,227,0.55); font: 13px ui-sans-serif, system-ui, sans-serif; }
|
|
16
54
|
`;
|
|
17
55
|
|
|
18
|
-
|
|
56
|
+
const esc = (s: unknown) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]!);
|
|
57
|
+
const short = (h: string) => (h && h.length > 14 ? `${h.slice(0, 8)}…${h.slice(-6)}` : h);
|
|
58
|
+
const num = (hex: string | null | undefined) => (hex ? BigInt(hex).toString() : '');
|
|
59
|
+
/** wei (hex) as ETH with up to 6 decimals, trailing zeros trimmed */
|
|
60
|
+
export const formatEth = (hex: string | null | undefined) => {
|
|
61
|
+
if (!hex) return '0';
|
|
62
|
+
const wei = BigInt(hex), whole = wei / 10n ** 18n, frac = (wei % 10n ** 18n).toString().padStart(18, '0').slice(0, 6).replace(/0+$/, '');
|
|
63
|
+
return frac ? `${whole}.${frac}` : whole.toString();
|
|
64
|
+
};
|
|
65
|
+
/** wei (hex) as gwei with up to 3 decimals */
|
|
66
|
+
const formatGwei = (hex: string | null | undefined) => { if (!hex) return '0'; const wei = BigInt(hex), whole = wei / 10n ** 9n, frac = (wei % 10n ** 9n).toString().padStart(9, '0').slice(0, 3).replace(/0+$/, ''); return frac ? `${whole}.${frac}` : whole.toString(); };
|
|
67
|
+
const fmtArgs = (args: any): string => Array.isArray(args) ? args.map(fmtArgs).join(', ') : args && typeof args === 'object' ? Object.entries(args).map(([k, v]) => `${k}: ${fmtArgs(v)}`).join(', ') : String(args);
|
|
68
|
+
const time = (hex: string | null) => (hex ? new Date(Number(BigInt(hex)) * 1000).toLocaleTimeString() : '');
|
|
69
|
+
|
|
70
|
+
export interface DevBarOptions {
|
|
71
|
+
/** start collapsed to the leaf (default false). A Hide/Show click is remembered in localStorage and overrides this */
|
|
72
|
+
hidden?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function mountDevBar(provider: Provider, opts: DevBarOptions = {}) {
|
|
19
76
|
if (document.getElementById('terrarium-devbar')) return;
|
|
20
77
|
const rpc = (method: string, params: unknown[] = []) => provider.request({ method, params });
|
|
21
78
|
const bar = document.createElement('footer');
|
|
@@ -23,47 +80,157 @@ export function mountDevBar(provider: Provider) {
|
|
|
23
80
|
const style = document.createElement('style'); style.textContent = CSS;
|
|
24
81
|
bar.append(style);
|
|
25
82
|
const el = (html: string) => { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild as HTMLElement; };
|
|
26
|
-
const btn = (label: string, testid: string, title: string, onClick: () => Promise<unknown> | void) => { const b = el(`<button data-testid="${testid}" title="${title}">${label}</button>`); b.onclick = () => Promise.resolve(onClick()).catch((e) => console.warn('[terrarium]', e)); return b; };
|
|
83
|
+
const btn = (label: string, testid: string, title: string, onClick: () => Promise<unknown> | void) => { const b = el(`<button data-testid="${testid}" title="${esc(title)}">${label}</button>`); b.onclick = () => Promise.resolve(onClick()).catch((e) => console.warn('[terrarium]', e)); return b; };
|
|
84
|
+
const group = (label: string, testid: string, ...children: HTMLElement[]) => { const g = el(`<div class="group" data-testid="${testid}"><span class="glabel">${label}</span></div>`); g.append(...children); return g; };
|
|
27
85
|
|
|
28
|
-
|
|
86
|
+
// ---- brand + scenario selector + status ---------------------------------------------------------------------------
|
|
87
|
+
const brand = el(`<span class="brand"><span class="tag">Terrarium</span><span class="name" data-testid="scenario-name" hidden></span></span>`);
|
|
88
|
+
const scenarioSelect = el(`<select data-testid="scenario" title="Which scenario the chain runs: switching stores the choice and reloads the page; each scenario keeps its own chain" hidden></select>`) as HTMLSelectElement;
|
|
89
|
+
brand.append(scenarioSelect);
|
|
90
|
+
let scenariosKey = '';
|
|
91
|
+
scenarioSelect.onchange = () => rpc('terrarium_selectScenario', [scenarioSelect.value]).catch((e) => console.warn('[terrarium]', e));
|
|
92
|
+
const status = el(`<span class="status">block <b data-testid="block" data-f="block">…</b> · chain <span data-f="chain">…</span><span data-f="engine"></span></span>`);
|
|
93
|
+
|
|
94
|
+
// ---- chain ------------------------------------------------------------------------------------------------------------
|
|
29
95
|
let mining: 'auto' | 'interval' = 'auto', snap: string | null = null;
|
|
30
96
|
const bMining = btn('Blocks: instant', 'mining', 'Auto: a block per transaction. Interval: a block every 3s, so you can watch pending states', async () => {
|
|
31
97
|
mining = mining === 'auto' ? 'interval' : 'auto';
|
|
32
98
|
await (mining === 'auto' ? rpc('evm_setAutomine', [true]) : rpc('evm_setIntervalMining', [3000]));
|
|
33
|
-
bMining.textContent = mining === 'auto' ? 'Blocks: instant' : 'Blocks: every 3s';
|
|
99
|
+
bMining.textContent = mining === 'auto' ? 'Blocks: instant' : 'Blocks: every 3s'; bMining.classList.toggle('on', mining === 'interval');
|
|
34
100
|
});
|
|
35
101
|
const bSnap = btn('Snapshot', 'snapshot', 'Snapshot the chain; revert brings blocks, receipts, journal and the UI history back', async () => {
|
|
36
|
-
if (snap) { await rpc('evm_revert', [snap]); snap = null; bSnap.textContent = 'Snapshot'; }
|
|
37
|
-
else { snap = await rpc('evm_snapshot'); bSnap.textContent = 'Revert to snapshot'; }
|
|
102
|
+
if (snap) { await rpc('evm_revert', [snap]); snap = null; bSnap.textContent = 'Snapshot'; bSnap.classList.remove('on'); }
|
|
103
|
+
else { snap = await rpc('evm_snapshot'); bSnap.textContent = 'Revert to snapshot'; bSnap.classList.add('on'); }
|
|
38
104
|
});
|
|
39
|
-
const
|
|
105
|
+
const gChain = group('chain', 'group-chain',
|
|
106
|
+
btn('Mine a block', 'mine', 'Mine one empty block', () => rpc('evm_mine')),
|
|
107
|
+
btn('+1 hour', 'plus-hour', 'Move the chain clock forward one hour', async () => { await rpc('evm_increaseTime', [3600]); await rpc('evm_mine'); }),
|
|
108
|
+
bMining, bSnap);
|
|
109
|
+
|
|
110
|
+
// ---- wallet -----------------------------------------------------------------------------------------------------------
|
|
40
111
|
const bReject = btn('Reject next tx', 'reject-next', 'The wallet rejects the next signature request (EIP-1193 error 4001), like a user hitting Cancel', () => rpc('terrarium_setWallet', [{ rejectNext: 1 }]));
|
|
41
112
|
const bLatency = btn('Wallet: instant', 'wallet-latency', 'Make the wallet take 2 seconds to answer, like a real one', async () => { const w = await rpc('terrarium_getWallet'); await rpc('terrarium_setWallet', [{ latencyMs: w.latencyMs ? 0 : 2000 }]); });
|
|
42
113
|
const bLag = btn('Receipts: instant', 'receipt-lag', 'Receipts appear 3 seconds after the block, like a node that has not caught up', async () => { const w = await rpc('terrarium_getWallet'); await rpc('terrarium_setWallet', [{ receiptLagMs: w.receiptLagMs ? 0 : 3000 }]); });
|
|
43
|
-
const
|
|
44
|
-
bReset.classList.add('danger');
|
|
114
|
+
const gWallet = group('wallet', 'group-wallet', bReject, bLatency, bLag);
|
|
45
115
|
|
|
116
|
+
// ---- the scenario's own knobs: actors + controls ------------------------------------------------------------------------
|
|
117
|
+
const bActors = btn('Actors off', 'actors', 'Scripted actors: other users, keepers, arbitrageurs trading on their own', () => rpc('terrarium_actors'));
|
|
46
118
|
const controls = el('<span class="controls" style="display:contents"></span>'); let controlsKey = '';
|
|
47
|
-
|
|
48
|
-
btn('Mine a block', 'mine', 'Mine one empty block', () => rpc('evm_mine')),
|
|
49
|
-
btn('+1 hour', 'plus-hour', 'Move the chain clock forward one hour', async () => { await rpc('evm_increaseTime', [3600]); await rpc('evm_mine'); }),
|
|
50
|
-
bMining, bSnap, bActors, bReject, bLatency, bLag, bReset);
|
|
51
|
-
document.body.append(bar);
|
|
52
|
-
document.body.style.paddingBottom = '64px';
|
|
119
|
+
const gScenario = group('scenario', 'group-scenario', bActors, controls);
|
|
53
120
|
|
|
121
|
+
// ---- the transaction explorer -----------------------------------------------------------------------------------------
|
|
122
|
+
const panel = el(`<section id="terrarium-explorer" data-testid="explorer" hidden></section>`);
|
|
123
|
+
const open = new Set<string>(); // expanded rows, by hash, kept across refreshes
|
|
124
|
+
let lastRender = '', filter: 'all' | 'mine' | 'failed' = 'all', me: string | null = null; // `me`: accounts[0] from terrarium_status
|
|
125
|
+
const bTxs = btn('Transactions', 'txs', 'Every transaction on this chain, like a block explorer: receipt, decoded call, events, revert reason', async () => {
|
|
126
|
+
panel.hidden = !panel.hidden; bTxs.classList.toggle('on', !panel.hidden); lastRender = '';
|
|
127
|
+
if (!panel.hidden) await refreshTxs();
|
|
128
|
+
});
|
|
129
|
+
const place = () => { panel.style.bottom = `${bar.offsetHeight}px`; };
|
|
130
|
+
const statusCell = (s: string) => s === 'success' ? '<span class="ok" title="success">✓</span>' : s === 'pending' ? '<span class="wait" title="pending">⏳</span>' : `<span class="bad" title="${s}">✗</span>`;
|
|
131
|
+
const who = (labels: Record<string, string>, a: string | null) => a ? `<span class="addr" title="${a}">${esc(labels[a.toLowerCase()] ?? short(a))}</span>` : '<span class="dim">contract creation</span>';
|
|
132
|
+
const render = (data: { total: number; labels: Record<string, string>; transactions: any[] }) => {
|
|
133
|
+
const key = JSON.stringify([data.total, data.transactions.map((t) => [t.hash, t.status]), [...open], filter, me]);
|
|
134
|
+
if (key === lastRender) return; lastRender = key;
|
|
135
|
+
const L = data.labels ?? {};
|
|
136
|
+
const shown = data.transactions.filter((t) => filter === 'all' ? true : filter === 'failed' ? t.status !== 'success' && t.status !== 'pending' : !!me && t.from?.toLowerCase() === me.toLowerCase());
|
|
137
|
+
const rows = shown.map((t) => {
|
|
138
|
+
const method = t.method ? (t.method.name ? `<span class="name">${esc(t.method.name)}</span>` : `<span class="dim">${esc(t.method.selector)}</span>`) : '<span class="dim">transfer</span>';
|
|
139
|
+
const isOpen = open.has(t.hash);
|
|
140
|
+
const detail = !isOpen ? '' : `<tr class="detail" data-testid="tx-detail"><td colspan="10"><dl>
|
|
141
|
+
<dt>hash</dt><dd class="hash">${esc(t.hash)}</dd>
|
|
142
|
+
<dt>status</dt><dd>${esc(t.status)}${t.status === 'reverted' || t.status === 'dropped' ? ` <span class="bad">· ${t.revert ? esc(t.revert.name) + '(' + esc(fmtArgs(t.revert.args)) + ')' : esc(t.error ?? '')}</span>` : ''}</dd>
|
|
143
|
+
<dt>block</dt><dd>${t.receipt ? `${num(t.receipt.blockNumber)} · ${esc(time(t.timestamp))} · index ${num(t.receipt.transactionIndex)}` : 'pending'}</dd>
|
|
144
|
+
<dt>from</dt><dd>${who(L, t.from)} <span class="dim">${esc(t.from)}</span></dd>
|
|
145
|
+
<dt>to</dt><dd>${t.to ? `${who(L, t.to)} <span class="dim">${esc(t.to)}</span>` : `<span class="dim">created</span> ${esc(t.receipt?.contractAddress ?? '')}`}</dd>
|
|
146
|
+
<dt>value</dt><dd>${formatEth(t.value)} ETH</dd>
|
|
147
|
+
<dt>nonce</dt><dd>${num(t.nonce)}</dd>
|
|
148
|
+
<dt>gas</dt><dd>${t.receipt ? `${num(t.receipt.gasUsed)} used` : ''} of ${num(t.gas)} limit${t.receipt ? ` · ${esc(formatGwei(t.receipt.effectiveGasPrice))} gwei/gas · fee ${esc(formatEth('0x' + (BigInt(t.receipt.gasUsed) * BigInt(t.receipt.effectiveGasPrice)).toString(16)))} ETH` : ''}</dd>
|
|
149
|
+
<dt>input</dt><dd>${t.method?.name ? `<span class="name">${esc(t.method.name)}</span>(<span class="args">${esc(fmtArgs(t.method.args))}</span>)<br>` : ''}<span class="dim">${esc(t.input === '0x' ? '0x (no data)' : t.input)}</span></dd>
|
|
150
|
+
${t.revertData && t.revertData !== '0x' ? `<dt>revert data</dt><dd class="dim">${esc(t.revertData)}</dd>` : ''}
|
|
151
|
+
</dl>
|
|
152
|
+
${t.logs?.length ? `<ul class="events" data-testid="tx-events">${t.logs.map((l: any) => `<li><span class="dim">#${num(l.logIndex)}</span> ${who(L, l.address)} ${l.decoded ? `<span class="name">${esc(l.decoded.name)}</span>(<span class="args">${esc(fmtArgs(l.decoded.args))}</span>)` : `<span class="dim">topics ${l.topics.map(esc).join(' ')}<br>data ${esc(l.data)}</span>`}</li>`).join('')}</ul>` : `<div class="dim" style="margin-top:8px">no events</div>`}
|
|
153
|
+
</td></tr>`;
|
|
154
|
+
return `<tr class="tx${isOpen ? ' open' : ''}" data-testid="tx-row" data-hash="${t.hash}" data-status="${t.status}">
|
|
155
|
+
<td>${statusCell(t.status)}</td><td>${t.receipt ? num(t.receipt.blockNumber) : '<span class="dim">pending</span>'}</td><td class="dim">${esc(time(t.timestamp))}</td>
|
|
156
|
+
<td class="hash" title="${t.hash}">${short(t.hash)}</td><td>${method}</td><td>${who(L, t.from)}</td><td>${who(L, t.to)}</td>
|
|
157
|
+
<td>${formatEth(t.value)} ETH</td><td class="dim">${t.receipt ? num(t.receipt.gasUsed) : ''}</td><td class="dim">${t.logs?.length ? `${t.logs.length} event${t.logs.length === 1 ? '' : 's'}` : ''}</td></tr>${detail}`;
|
|
158
|
+
}).join('');
|
|
159
|
+
panel.innerHTML = `<div class="head"><b>Transactions</b> <span>${data.total} on this chain, newest first${data.total > data.transactions.length ? ` (showing ${data.transactions.length})` : ''}${filter !== 'all' ? `, ${shown.length} ${filter === 'mine' ? 'from you' : 'failed'}` : ''}</span>
|
|
160
|
+
<select data-testid="tx-filter" title="Which transactions to list"><option value="all"${filter === 'all' ? ' selected' : ''}>all</option><option value="mine"${filter === 'mine' ? ' selected' : ''}>mine (account #0)</option><option value="failed"${filter === 'failed' ? ' selected' : ''}>failed</option></select>
|
|
161
|
+
<span class="spacer" style="flex:1"></span><span class="dim">click a row for the receipt, the decoded call and the events</span></div>`
|
|
162
|
+
+ (rows ? `<table><thead><tr><th></th><th>block</th><th>time</th><th>hash</th><th>method</th><th>from</th><th>to</th><th>value</th><th>gas used</th><th>events</th></tr></thead><tbody>${rows}</tbody></table>` : `<div class="empty">${data.total ? 'Nothing matches this filter.' : 'No transactions yet. Do something in the dapp and it appears here.'}</div>`);
|
|
163
|
+
place();
|
|
164
|
+
};
|
|
165
|
+
panel.addEventListener('change', (e) => { const sel = e.target as HTMLSelectElement; if (sel.dataset.testid === 'tx-filter') { filter = sel.value as typeof filter; lastRender = ''; refreshTxs(); } });
|
|
166
|
+
panel.addEventListener('click', (e) => {
|
|
167
|
+
const row = (e.target as HTMLElement).closest<HTMLElement>('tr.tx'); if (!row) return;
|
|
168
|
+
const h = row.dataset.hash!; open.has(h) ? open.delete(h) : open.add(h); lastRender = ''; refreshTxs();
|
|
169
|
+
});
|
|
170
|
+
const refreshTxs = async () => { const data = await rpc('terrarium_transactions', [{ limit: 200 }]).catch(() => null); if (data) render(data); };
|
|
171
|
+
|
|
172
|
+
// ---- reset, hide / show -------------------------------------------------------------------------------------------------
|
|
173
|
+
const bReset = btn('Reset', 'reset', 'Wipe this scenario\'s chain and boot it again from scratch', async () => { await rpc('terrarium_reset'); location.reload(); });
|
|
174
|
+
bReset.classList.add('danger');
|
|
175
|
+
const pill = el(`<button id="terrarium-devbar-show" data-testid="show" title="Show the Terrarium dev bar" hidden>🌱</button>`);
|
|
176
|
+
const remember = (hidden: boolean) => { try { localStorage.setItem(HIDDEN_KEY, hidden ? '1' : '0'); } catch {} };
|
|
177
|
+
const setHidden = (hidden: boolean) => {
|
|
178
|
+
bar.hidden = hidden; pill.hidden = !hidden;
|
|
179
|
+
if (hidden) { panel.hidden = true; bTxs.classList.remove('on'); }
|
|
180
|
+
document.body.style.paddingBottom = hidden ? '' : '56px';
|
|
181
|
+
remember(hidden);
|
|
182
|
+
};
|
|
183
|
+
const bHide = btn('Hide', 'hide', 'Hide the dev bar (the chain keeps running); the leaf at the bottom right brings it back', () => setHidden(true));
|
|
184
|
+
bHide.classList.add('quiet');
|
|
185
|
+
pill.onclick = () => setHidden(false);
|
|
186
|
+
const gTools = group('', 'group-tools', bTxs, bReset, bHide);
|
|
187
|
+
|
|
188
|
+
bar.append(brand, status, el('<span class="spacer"></span>'), gChain, gWallet, gScenario, gTools, panel);
|
|
189
|
+
document.body.append(bar, pill);
|
|
190
|
+
let startHidden = !!opts.hidden; try { const v = localStorage.getItem(HIDDEN_KEY); if (v !== null) startHidden = v === '1'; } catch {}
|
|
191
|
+
bar.hidden = startHidden; pill.hidden = !startHidden; document.body.style.paddingBottom = startHidden ? '' : '56px'; // apply without remembering: the default is not a choice
|
|
192
|
+
|
|
193
|
+
// ---- polling: status every 500 ms, the explorer while open, the scenario list now and then -----------------------------------
|
|
194
|
+
const refreshScenarios = async () => {
|
|
195
|
+
const s = await rpc('terrarium_scenarios').catch(() => null); if (!s) return;
|
|
196
|
+
const k = JSON.stringify(s); if (k === scenariosKey) return; scenariosKey = k;
|
|
197
|
+
const many = s.scenarios.length > 1;
|
|
198
|
+
scenarioSelect.hidden = !many;
|
|
199
|
+
const name = brand.querySelector<HTMLElement>('[data-testid=scenario-name]')!;
|
|
200
|
+
name.hidden = many || !s.active || /^Scenario \d+$/.test(s.active); name.textContent = s.active ?? '';
|
|
201
|
+
if (many) {
|
|
202
|
+
scenarioSelect.replaceChildren(...s.scenarios.map((sc: any) => { const o = document.createElement('option'); o.value = sc.name; o.textContent = sc.name; if (sc.description) o.title = sc.description; o.selected = sc.name === s.active; return o; }));
|
|
203
|
+
const active = s.scenarios.find((sc: any) => sc.name === s.active); if (active?.description) scenarioSelect.title = `${active.description}\n\nSwitching stores the choice and reloads the page; each scenario keeps its own chain.`;
|
|
204
|
+
}
|
|
205
|
+
};
|
|
54
206
|
const refresh = async () => {
|
|
55
207
|
const s = await rpc('terrarium_status').catch(() => null); if (!s) return;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
208
|
+
me = s.accounts?.[0] ?? me;
|
|
209
|
+
status.querySelector('[data-f=chain]')!.textContent = String(s.chainId);
|
|
210
|
+
status.querySelector('[data-f=block]')!.textContent = String(parseInt(s.block, 16));
|
|
211
|
+
const notes: string[] = [];
|
|
212
|
+
if (s.fork) notes.push(`fork @${s.fork.blockNumber}${s.fork.offline ? ' offline' : ''}${s.fork.misses ? ` · <span class="warn">${s.fork.misses} MISSES</span>` : ''}`);
|
|
213
|
+
if (s.http?.routes) notes.push(`${s.http.routes} HTTP route${s.http.routes === 1 ? '' : 's'}, ${s.http.hits} answered`);
|
|
214
|
+
if (s.restoredFromPersistence) notes.push(`${s.localBlocks} block${s.localBlocks === 1 ? '' : 's'} restored`);
|
|
215
|
+
status.querySelector('[data-f=engine]')!.innerHTML = notes.length ? ' · ' + notes.join(' · ') : '';
|
|
216
|
+
status.title = `revm/wasm · the chain persists in IndexedDB${s.restoredFromPersistence ? ' (Reset to start clean)' : ''}`;
|
|
61
217
|
const ck = JSON.stringify(s.controls ?? []);
|
|
62
218
|
if (ck !== controlsKey) { controlsKey = ck; controls.replaceChildren(...(s.controls ?? []).map((c: any, i: number) => btn(c.label, `control-${i}`, c.title ?? c.method, () => rpc(c.method, c.params ?? [])))); }
|
|
63
219
|
bActors.hidden = !s.hasActors; bActors.textContent = `${s.actorsLabel} ${s.actors ? 'on' : 'off'}`; bActors.classList.toggle('on', s.actors);
|
|
220
|
+
gScenario.hidden = !s.hasActors && !(s.controls?.length);
|
|
64
221
|
bReject.textContent = s.wallet.rejectNext > 0 ? `Reject next tx · armed (${s.wallet.rejectNext})` : 'Reject next tx'; bReject.classList.toggle('armed', s.wallet.rejectNext > 0);
|
|
65
222
|
bLatency.textContent = s.wallet.latencyMs ? `Wallet: ${s.wallet.latencyMs / 1000}s delay` : 'Wallet: instant'; bLatency.classList.toggle('on', !!s.wallet.latencyMs);
|
|
66
223
|
bLag.textContent = s.wallet.receiptLagMs ? `Receipts: ${s.wallet.receiptLagMs / 1000}s late` : 'Receipts: instant'; bLag.classList.toggle('on', !!s.wallet.receiptLagMs);
|
|
224
|
+
if (!panel.hidden) { await refreshTxs(); place(); }
|
|
67
225
|
};
|
|
68
|
-
refresh();
|
|
226
|
+
refresh(); refreshScenarios();
|
|
227
|
+
let ticks = 0;
|
|
228
|
+
const timer = setInterval(() => { if (!document.getElementById('terrarium-devbar')) return clearInterval(timer); refresh(); if (++ticks % 10 === 0) refreshScenarios(); }, 500);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Remove the dev bar, its explorer and the show pill; restore the page's bottom padding. Idempotent. */
|
|
232
|
+
export function unmountDevBar() {
|
|
233
|
+
document.getElementById('terrarium-devbar')?.remove();
|
|
234
|
+
document.getElementById('terrarium-devbar-show')?.remove();
|
|
235
|
+
document.body?.style.removeProperty('padding-bottom');
|
|
69
236
|
}
|
package/src/engine.js
CHANGED
|
@@ -66,7 +66,7 @@ class RecordingRPCStateManager extends RPCStateManager {
|
|
|
66
66
|
async getStorage(address, key) { if (this.knownAbsent(address) && this._caches.storage.get(address, key) === undefined) return new Uint8Array(); const rk = `${address.toString()}_${bytesToHex(key)}`; if (this._caches.storage.get(address, key) === undefined && this.remote.storage.has(rk)) { const v = this.remote.storage.get(rk); this._caches.storage.put(address, key, v); return v; } if (this.offline && this._caches.storage.get(address, key) === undefined) this.miss('storage', `${address.toString()}:${bytesToHex(key)}`); if (globalThis.process?.env?.TERRARIUM_DEBUG && this._caches.storage.get(address, key) === undefined) console.log('[remote] storage', address.toString(), bytesToHex(key)); const v = await this.retry(() => super.getStorage(address, key)); this.remote.storage.set(`${address.toString()}_${bytesToHex(key)}`, v); return v; }
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
const STATE_CHANGING = new Set(['eth_sendTransaction', 'eth_sendRawTransaction', 'evm_mine', 'anvil_mine', 'hardhat_mine', 'evm_setNextBlockTimestamp', 'anvil_setNextBlockTimestamp', 'evm_increaseTime', 'anvil_increaseTime', 'evm_setAutomine', 'anvil_setAutomine', 'anvil_setBalance', 'hardhat_setBalance', 'anvil_setCode', 'hardhat_setCode', 'anvil_setNonce', 'hardhat_setNonce', 'anvil_setStorageAt', 'hardhat_setStorageAt', 'anvil_impersonateAccount', 'hardhat_impersonateAccount', 'anvil_stopImpersonatingAccount', 'hardhat_stopImpersonatingAccount', 'anvil_setNextBlockBaseFeePerGas', 'hardhat_setNextBlockBaseFeePerGas', 'sim_deal', 'sim_setState']);
|
|
69
|
+
const STATE_CHANGING = new Set(['eth_sendTransaction', 'eth_sendRawTransaction', 'evm_mine', 'anvil_mine', 'hardhat_mine', 'evm_setNextBlockTimestamp', 'anvil_setNextBlockTimestamp', 'evm_increaseTime', 'anvil_increaseTime', 'evm_setAutomine', 'anvil_setAutomine', 'anvil_setBalance', 'hardhat_setBalance', 'anvil_setCode', 'hardhat_setCode', 'anvil_setNonce', 'hardhat_setNonce', 'anvil_setStorageAt', 'hardhat_setStorageAt', 'anvil_loadState', 'hardhat_loadState', 'anvil_impersonateAccount', 'hardhat_impersonateAccount', 'anvil_stopImpersonatingAccount', 'hardhat_stopImpersonatingAccount', 'anvil_setNextBlockBaseFeePerGas', 'hardhat_setNextBlockBaseFeePerGas', 'sim_deal', 'sim_setState']);
|
|
70
70
|
const pad32 = (h) => pad(typeof h === 'bigint' ? numberToHex(h, { size: 32 }) : h, { size: 32 });
|
|
71
71
|
|
|
72
72
|
/** small deterministic PRNG (mulberry32) so scripted actors are reproducible when a seed is given */
|
|
@@ -364,11 +364,12 @@ export async function createTerrarium(opts = {}) {
|
|
|
364
364
|
r = await exec({ tx: entry.tx, block, flags: { blockGasUsed: cumulative } });
|
|
365
365
|
} catch (e) { // invalid tx (nonce too low, insufficient funds...): a node would drop it silently and the dapp
|
|
366
366
|
// would wait for a receipt forever. Record a failed receipt instead so waiters resolve and the reason is visible.
|
|
367
|
-
const t = txs.get(entry.hash); t.error = String(e.message);
|
|
367
|
+
const t = txs.get(entry.hash); t.error = String(e.message); t.dropped = true;
|
|
368
368
|
receipts.push({ transactionHash: entry.hash, transactionIndex: hex(idx), from: entry.from, to: entry.rpc.to, cumulativeGasUsed: hex(cumulative), gasUsed: '0x0', effectiveGasPrice: hex(baseFee), contractAddress: null, logs: [], logsBloom: '0x' + '00'.repeat(256), status: '0x0', type: '0x2', droppedReason: t.error });
|
|
369
369
|
hashes.push(entry.hash); sealTxs.push(entry.tx); sealReceipts.push({ status: 0, cumulativeBlockGasUsed: cumulative, bitvector: new Uint8Array(256), logs: [] }); continue;
|
|
370
370
|
}
|
|
371
371
|
cumulative += r.gasUsed;
|
|
372
|
+
if (!r.success) { const t = txs.get(entry.hash); t.error = r.error ?? 'execution reverted'; t.revertData = bytesToHex(r.returnValue); }
|
|
372
373
|
if (globalThis.process?.env?.TERRARIUM_DEBUG && !r.success) console.log('[tx reverted]', entry.hash, r.error, bytesToHex(r.returnValue), 'gasUsed', r.gasUsed, 'gasLimit', entry.tx.gasLimit);
|
|
373
374
|
const bloom = bloomOf(r.logs);
|
|
374
375
|
const logs = r.logs.map(([addr, topics, data]) => ({ address: bytesToHex(addr), topics: topics.map(bytesToHex), data: bytesToHex(data), blockNumber: hex(header.number), transactionHash: entry.hash, transactionIndex: hex(idx), logIndex: hex(logIndex++), removed: false }));
|
|
@@ -447,6 +448,19 @@ export async function createTerrarium(opts = {}) {
|
|
|
447
448
|
return hi;
|
|
448
449
|
}
|
|
449
450
|
|
|
451
|
+
/** Transactions newest first, as an explorer lists them: the RPC tx merged with its receipt, a `status` word, the
|
|
452
|
+
* revert reason and data of a failed one, and the block timestamp. Pending (interval mining) come first. */
|
|
453
|
+
function listTransactions({ limit = 50, before } = {}) {
|
|
454
|
+
const all = [...txs.values()].reverse().map((t) => {
|
|
455
|
+
const mined = !!t.receipt, n = mined ? hexToBigInt(t.receipt.blockNumber) : null;
|
|
456
|
+
const b = mined ? blocks.find((b) => BigInt(b.number) === n) : null;
|
|
457
|
+
const status = !mined ? 'pending' : t.receipt.status === '0x1' ? 'success' : t.dropped ? 'dropped' : 'reverted';
|
|
458
|
+
return { ...t.rpc, status, receipt: t.receipt, timestamp: b ? hex(b.timestamp) : null, error: t.error ?? null, revertData: t.revertData ?? null };
|
|
459
|
+
});
|
|
460
|
+
const from = before ? all.findIndex((t) => t.hash === before) + 1 : 0;
|
|
461
|
+
return { total: all.length, transactions: all.slice(from, from + Math.max(0, limit)) };
|
|
462
|
+
}
|
|
463
|
+
|
|
450
464
|
// ---- RPC formatting ----------------------------------------------------------------------------
|
|
451
465
|
function rpcBlock(b, full) {
|
|
452
466
|
const transactions = full ? b.transactions.map((h) => txs.get(h).rpc) : b.transactions;
|
|
@@ -540,6 +554,7 @@ export async function createTerrarium(opts = {}) {
|
|
|
540
554
|
case 'eth_sendRawTransaction': { const tx = createTxFromRLP(hexToBytes(params[0]), { common }); return submit(tx, tx.getSenderAddress().toString()); }
|
|
541
555
|
case 'eth_getTransactionReceipt': { const t = txs.get(params[0]); if (!t?.receipt) return null; if (walletKnobs.receiptLagMs > 0 && Date.now() - (t.minedAt ?? 0) < walletKnobs.receiptLagMs) return null; return t.receipt; }
|
|
542
556
|
case 'eth_getTransactionByHash': return txs.get(params[0])?.rpc ?? null;
|
|
557
|
+
case 'terrarium_transactions': return listTransactions(params[0] ?? {});
|
|
543
558
|
case 'eth_getLogs': return getLogs(params[0] ?? {});
|
|
544
559
|
case 'eth_newFilter': filters.set(hex(nextFilterId), { type: 'logs', f: params[0] ?? {}, cursor: latest().number + 1n }); return hex(nextFilterId++);
|
|
545
560
|
case 'eth_newBlockFilter': filters.set(hex(nextFilterId), { type: 'blocks', cursor: latest().number + 1n }); return hex(nextFilterId++);
|
|
@@ -568,6 +583,7 @@ export async function createTerrarium(opts = {}) {
|
|
|
568
583
|
case 'anvil_setCode': case 'hardhat_setCode': await sm.putCode(createAddressFromString(params[0]), hexToBytes(params[1])); return null;
|
|
569
584
|
case 'anvil_setNonce': case 'hardhat_setNonce': await sm.modifyAccountFields(createAddressFromString(params[0]), { nonce: hexToBigInt(params[1]) }); return null;
|
|
570
585
|
case 'anvil_setStorageAt': case 'hardhat_setStorageAt': await sm.putStorage(createAddressFromString(params[0]), hexToBytes(numberToHex(hexToBigInt(params[1]), { size: 32 })), hexToBytes(numberToHex(hexToBigInt(params[2]), { size: 32 }))); return null;
|
|
586
|
+
case 'anvil_loadState': case 'hardhat_loadState': return loadAnvilState(params[0]);
|
|
571
587
|
case 'anvil_impersonateAccount': case 'hardhat_impersonateAccount': impersonated.add(params[0].toLowerCase()); return null;
|
|
572
588
|
case 'anvil_stopImpersonatingAccount': case 'hardhat_stopImpersonatingAccount': impersonated.delete(params[0].toLowerCase()); return null;
|
|
573
589
|
case 'anvil_setNextBlockBaseFeePerGas': case 'hardhat_setNextBlockBaseFeePerGas': baseFee = hexToBigInt(params[0]); return null;
|
|
@@ -694,6 +710,37 @@ export async function createTerrarium(opts = {}) {
|
|
|
694
710
|
return written;
|
|
695
711
|
}
|
|
696
712
|
|
|
713
|
+
// ---- anvil_loadState: an Anvil state dump (anvil_dumpState / --dump-state) written into this chain ------------------
|
|
714
|
+
// Accepts what Anvil hands out: the gzipped JSON as a hex string, or the parsed object. Every account's code, nonce,
|
|
715
|
+
// balance and storage land through the state manager like the single cheatcodes do, in one journaled call. Deploy a
|
|
716
|
+
// protocol with its own tooling (Foundry scripts, Hardhat deploys) against Anvil, dump, and boot the Terrarium from it.
|
|
717
|
+
async function loadAnvilState(input) {
|
|
718
|
+
const dump = typeof input === 'string' ? await parseAnvilDump(input) : input;
|
|
719
|
+
const accounts = dump?.accounts ?? dump;
|
|
720
|
+
if (!accounts || typeof accounts !== 'object') throw new RpcError(-32602, 'anvil_loadState: expected an Anvil state dump ({ accounts: { address: { nonce, balance, code, storage } } })');
|
|
721
|
+
let n = 0, slots = 0;
|
|
722
|
+
for (const [a, acct] of Object.entries(accounts)) {
|
|
723
|
+
if (!acct) continue;
|
|
724
|
+
const addr = createAddressFromString(a);
|
|
725
|
+
if (acct.code && acct.code !== '0x') await sm.putCode(addr, hexToBytes(acct.code));
|
|
726
|
+
const fields = {};
|
|
727
|
+
if (acct.nonce !== undefined) fields.nonce = BigInt(acct.nonce);
|
|
728
|
+
if (acct.balance !== undefined) fields.balance = BigInt(acct.balance);
|
|
729
|
+
if (Object.keys(fields).length) await sm.modifyAccountFields(addr, fields);
|
|
730
|
+
for (const [k, v] of Object.entries(acct.storage ?? {})) { await sm.putStorage(addr, hexToBytes(pad32(hexToBigInt(k))), hexToBytes(pad32(hexToBigInt(v)))); slots++; }
|
|
731
|
+
n++;
|
|
732
|
+
}
|
|
733
|
+
return { accounts: n, slots };
|
|
734
|
+
}
|
|
735
|
+
/** Anvil's wire format: hex of gzipped JSON. Inflated with the platform's DecompressionStream (browsers, Workers, Node 18+). */
|
|
736
|
+
async function parseAnvilDump(hexDump) {
|
|
737
|
+
const bytes = hexToBytes(hexDump);
|
|
738
|
+
if (bytes[0] !== 0x1f || bytes[1] !== 0x8b) return JSON.parse(new TextDecoder().decode(bytes)); // not gzipped: plain JSON as hex
|
|
739
|
+
if (typeof DecompressionStream === 'undefined') throw new RpcError(-32000, 'anvil_loadState: gzipped dump but no DecompressionStream here; pass the parsed object');
|
|
740
|
+
const inflated = await new Response(new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip'))).text();
|
|
741
|
+
return JSON.parse(inflated);
|
|
742
|
+
}
|
|
743
|
+
|
|
697
744
|
// ---- persistence: dump the diff, restore it, or replay the journal -------------------------------
|
|
698
745
|
const serBlock = (b) => ({ ...b, number: hex(b.number), timestamp: hex(b.timestamp), gasLimit: hex(b.gasLimit), gasUsed: hex(b.gasUsed), baseFeePerGas: hex(b.baseFeePerGas) });
|
|
699
746
|
const deserBlock = (b) => ({ ...b, number: hexToBigInt(b.number), timestamp: hexToBigInt(b.timestamp), gasLimit: hexToBigInt(b.gasLimit), gasUsed: hexToBigInt(b.gasUsed), baseFeePerGas: hexToBigInt(b.baseFeePerGas) });
|
|
@@ -706,7 +753,7 @@ export async function createTerrarium(opts = {}) {
|
|
|
706
753
|
const remote = sm.remote ? { accounts: Object.fromEntries([...sm.remote.accounts].map(([a, acct]) => [a, acct ? { nonce: hex(acct.nonce), balance: hex(acct.balance), codeHash: bytesToHex(acct.codeHash) } : null])), code: Object.fromEntries([...sm.remote.code].map(([a, c]) => [a, bytesToHex(c)])), storage: Object.fromEntries([...sm.remote.storage].map(([k, v]) => [k, bytesToHex(v)])) } : undefined;
|
|
707
754
|
return { version: 1, chainId, savedAt: Date.now(), state: { accounts: accountsOut, code: codeOut, storage: storageOut }, remote,
|
|
708
755
|
// tx bodies: only the most recent blocks' worth (like a pruned node), and without their logs (rebuilt from block logs on load)
|
|
709
|
-
chain: { blocks: blocks.map(serBlock), txs: Object.fromEntries([...txs].filter(([, t]) => !t.receipt || hexToBigInt(t.receipt.blockNumber) >= keepFrom).map(([h, t]) => [h, { rpc: t.rpc, receipt: t.receipt ? { ...t.receipt, logs: undefined } : null, error: t.error }])), timeOffset: hex(timeOffset), baseFee: hex(baseFee), mining, impersonated: [...impersonated], dealtSlots: Object.fromEntries(dealtSlots) },
|
|
756
|
+
chain: { blocks: blocks.map(serBlock), txs: Object.fromEntries([...txs].filter(([, t]) => !t.receipt || hexToBigInt(t.receipt.blockNumber) >= keepFrom).map(([h, t]) => [h, { rpc: t.rpc, receipt: t.receipt ? { ...t.receipt, logs: undefined } : null, error: t.error, revertData: t.revertData, dropped: t.dropped }])), timeOffset: hex(timeOffset), baseFee: hex(baseFee), mining, impersonated: [...impersonated], dealtSlots: Object.fromEntries(dealtSlots) },
|
|
710
757
|
journal: { entries: journal, timestamps: blocks.slice(1).map((b) => hex(b.timestamp)) } };
|
|
711
758
|
}
|
|
712
759
|
async function seedState({ accounts: accs = {}, code = {}, storage = {} }) {
|
|
@@ -763,6 +810,8 @@ export async function createTerrarium(opts = {}) {
|
|
|
763
810
|
dumpState: () => exclusive(dumpState), loadState: (d) => exclusive(() => loadState(d)), replayJournal: (j) => exclusive(() => replayJournal(j)),
|
|
764
811
|
get journal() { return journal.slice(); }, flush: () => { clearTimeout(persistTimer); return persister ? exclusive(async () => persister.setItem(persistKey, JSON.stringify(await dumpState()))) : Promise.resolve(); },
|
|
765
812
|
get blockNumber() { return latest().number; },
|
|
813
|
+
/** transactions newest first ({ total, transactions }): tx + receipt + status word + revert reason/data + block timestamp */
|
|
814
|
+
transactions: (o) => listTransactions(o),
|
|
766
815
|
/** React to on-chain events with scripted actors (keepers, oracles, other users, bridges...). */
|
|
767
816
|
onLog(filter, handler) { const l = { filter, handler }; logListeners.push(l); return () => logListeners.splice(logListeners.indexOf(l), 1); },
|
|
768
817
|
/** Cheat: send a tx "from" any address without keys (impersonation), e.g. to simulate another user. */
|
package/src/inject.ts
CHANGED
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
// wallet (so the dapp's own connect modal lists "Terrarium Wallet") and mount the dev bar. Nothing here is imported by
|
|
3
3
|
// the dapp; this is the analogue of a browser extension's injected script.
|
|
4
4
|
import { createWorkerProvider } from './bridge.ts';
|
|
5
|
-
import { mountDevBar } from './devbar.ts';
|
|
5
|
+
import { mountDevBar, unmountDevBar } from './devbar.ts';
|
|
6
6
|
import { installHttpInterceptor, type WireRoute } from './http.ts';
|
|
7
7
|
|
|
8
8
|
const ICON = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#1F6F5C"/><path d="M6 25.5c2-6 5.5-8.5 10-8.5s8 2.5 10 8.5" fill="none" stroke="#E8C547" stroke-width="2.5" stroke-linecap="round"/><path d="M16 17.5V9" stroke="#E8C547" stroke-width="2.5" stroke-linecap="round"/><path d="M16 12c0-4.5 3-7 7-7 0 4.5-3 7-7 7Z M16 14.5c0-4.5-3-7-7-7 0 4.5 3 7 7 7Z" fill="#E8C547"/></svg>');
|
|
9
9
|
|
|
10
10
|
export interface StartOptions {
|
|
11
|
-
/** mount the dev bar (default true);
|
|
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
|
|
|
@@ -38,7 +41,6 @@ export function stopTerrarium() {
|
|
|
38
41
|
window.removeEventListener('eip6963:requestProvider', current.announce);
|
|
39
42
|
current.worker.terminate();
|
|
40
43
|
current = null;
|
|
41
|
-
|
|
42
|
-
document.body?.style.removeProperty('padding-bottom');
|
|
44
|
+
unmountDevBar();
|
|
43
45
|
delete (window as any).terrarium;
|
|
44
46
|
}
|
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
|
@@ -6,13 +6,16 @@
|
|
|
6
6
|
// actors: [{ every: 5000, run: (ctx) => ... }, { on: { address, topics }, run: (ctx, log) => ... }],
|
|
7
7
|
// status: (ctx) => ({ addresses: ctx.state }),
|
|
8
8
|
// });
|
|
9
|
-
import type { Account, Address, Chain, Hex, PublicClient, TransactionReceipt, Transport, WalletClient } from 'viem';
|
|
9
|
+
import type { Abi, Account, Address, Chain, Hex, PublicClient, TransactionReceipt, Transport, WalletClient } from 'viem';
|
|
10
10
|
import type { HttpRoute } from './http.ts';
|
|
11
11
|
export { reply } from './http.ts';
|
|
12
12
|
export type { HttpRoute, HttpRequest, HttpReply, GraphqlQuery, GraphqlResolver } from './http.ts';
|
|
13
13
|
|
|
14
14
|
/** Runtime bytecode of deployed contracts, installed at fixed addresses (`terrarium fetch-code` produces these). */
|
|
15
|
-
export interface Fixture { contracts: Record<string, { address: string; code: string }> } // plain strings: JSON imports fit as-is
|
|
15
|
+
export interface Fixture extends FixtureMeta { contracts: Record<string, { address: string; code: string }> } // plain strings: JSON imports fit as-is
|
|
16
|
+
/** what any fixture may add for the transaction explorer: names and ABIs by address (`terrarium import-anvil --broadcast --artifacts`
|
|
17
|
+
* fills them from a Foundry deployment). `install(fixture)` registers them like `ctx.label` would */
|
|
18
|
+
export interface FixtureMeta { names?: Record<string, string>; abis?: Record<string, Abi | readonly unknown[]> }
|
|
16
19
|
export interface LogFilter { address?: Address | Address[]; topics?: (Hex | Hex[] | null)[] }
|
|
17
20
|
|
|
18
21
|
export interface ScenarioContext {
|
|
@@ -36,14 +39,31 @@ export interface ScenarioContext {
|
|
|
36
39
|
/** true when nothing was persisted yet (first boot or after a reset), even if a fixture was restored: seed the user once */
|
|
37
40
|
firstBoot: boolean;
|
|
38
41
|
codeAt(address: Address): Promise<Hex>;
|
|
39
|
-
/** put a fixture
|
|
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,6 +114,18 @@ export interface ScenarioConfig {
|
|
|
89
114
|
* intercepted for matching URLs, the handler runs here in the Worker with `ctx`, everything else goes to the network.
|
|
90
115
|
* http: [{ match: 'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v2', graphql: { swaps: (ctx, q) => … } }] */
|
|
91
116
|
http?: HttpRoute[];
|
|
117
|
+
/** extra ABIs for the transaction explorer (`terrarium_transactions`): calls, events and custom errors decode with an
|
|
118
|
+
* address's own ABI (`ctx.label(address, name, abi)`) first, then these, then the built-in set of standard events, functions
|
|
119
|
+
* and errors (ERC-20/721/1155/4626, WETH, Uniswap V2, Ownable, proxies, OpenZeppelin errors). Anything left is shown raw */
|
|
120
|
+
abis?: Abi[];
|
|
121
|
+
/** names for addresses known up front (`{ [ROUTER]: 'Uniswap V2 Router' }`); for addresses discovered in setup use
|
|
122
|
+
* `ctx.label(address, name)`. The sim's accounts are `Account #i` unless named */
|
|
123
|
+
labels?: Record<string, string>;
|
|
92
124
|
}
|
|
93
125
|
|
|
94
126
|
export function defineScenario(config: ScenarioConfig): ScenarioConfig { return config; }
|
|
127
|
+
/** several scenarios in one file: the dev bar shows a selector, each keeps its own persisted chain, the choice survives
|
|
128
|
+
* reloads. `export default defineScenarios([fresh, afterCrash, indexerDown])`; a plain array works too */
|
|
129
|
+
export function defineScenarios(list: ScenarioConfig[]): ScenarioConfig[] { return list; }
|
|
130
|
+
/** what the Worker entry hands to `runScenario`: one scenario or a list */
|
|
131
|
+
export type ScenarioInput = ScenarioConfig | ScenarioConfig[];
|
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, toHex, type Address, type Hex } from 'viem';
|
|
3
|
+
import { createPublicClient, createWalletClient, custom, defineChain, decodeErrorResult, decodeEventLog, decodeFunctionData, toEventSelector, toHex, type Abi, type Address, type Hex } from 'viem';
|
|
4
|
+
import { KNOWN_ABI } from './known-abi.ts';
|
|
4
5
|
// @ts-ignore — the engine is plain ESM JavaScript
|
|
5
6
|
import { createTerrarium, indexedDBStorage } from './engine.js';
|
|
6
7
|
import { serveProvider } from './bridge.ts';
|
|
7
8
|
import { runRoute, toWire } from './http.ts';
|
|
8
|
-
import type { ScenarioConfig, ScenarioContext } from './scenario.ts';
|
|
9
|
+
import type { Actor, ScenarioConfig, ScenarioContext, ScenarioInput } from './scenario.ts';
|
|
9
10
|
|
|
10
|
-
|
|
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,34 +53,98 @@ export async function runScenario(config: ScenarioConfig) {
|
|
|
34
53
|
fresh: sim.blockNumber === 0n,
|
|
35
54
|
firstBoot,
|
|
36
55
|
codeAt: async (a) => (await rpc('eth_getCode', [a, 'latest'])) as Hex,
|
|
37
|
-
install: async (fixture) => {
|
|
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
|
-
|
|
111
|
+
sim.addMethod('terrarium_status', async () => ({ scenario: scenarioName, chainId, engine: sim.engine, block: toHex(sim.blockNumber), accounts: ctx.accounts, actors: actors.enabled, actorsLabel: config.actorsLabel ?? 'Actors', hasActors: toggled.length > 0, wallet: { ...sim.wallet }, controls: config.controls ?? [], restoredFromPersistence: sim.restoredFromPersistence, localBlocks: Number(sim.blockNumber) - (config.fork ? config.fork.blockNumber + 1 : 0), http: { routes: httpRoutes.length, hits: httpHits }, fork: config.fork ? { blockNumber: config.fork.blockNumber, offline: !!config.fork.offline, misses: sim.offlineMisses.length } : null, ...(await config.status?.(ctx)) }));
|
|
112
|
+
// ---- the transaction explorer: the engine's list, decoded (address's own ABI, then `abis`, then the known set) and labelled
|
|
113
|
+
const configAbi = (config.abis ?? []).flat() as Abi;
|
|
114
|
+
const abisFor = (address: string | null): Abi[] => [registry.get((address ?? '').toLowerCase())?.abi, configAbi, KNOWN_ABI].filter((a): a is Abi => !!a && a.length > 0);
|
|
115
|
+
const labelsOf = () => {
|
|
116
|
+
const out: Record<string, string> = {};
|
|
117
|
+
ctx.accounts.forEach((a, i) => { out[a.toLowerCase()] = i === 0 ? 'You (#0)' : `Account #${i}`; }); // accounts[0] is the browser user
|
|
118
|
+
for (const [a, e] of registry) if (e.name && e.auto) out[a] = e.name; // fixture keys
|
|
119
|
+
for (const [a, name] of Object.entries(config.labels ?? {})) if (a) out[a.toLowerCase()] = name; // the config
|
|
120
|
+
for (const [a, e] of registry) if (e.name && !e.auto) out[a] = e.name; // ctx.label() wins
|
|
121
|
+
return out;
|
|
122
|
+
};
|
|
123
|
+
const plain = (v: any): any => typeof v === 'bigint' ? v.toString() : Array.isArray(v) ? v.map(plain) : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, plain(x)])) : v;
|
|
124
|
+
const first = <T,>(items: readonly any[], fn: (item: any) => T | null): T | null => { for (const item of items) { try { const r = fn(item); if (r) return r; } catch {} } return null; };
|
|
125
|
+
const decodeCall = (to: string, data: Hex) => first(abisFor(to), (abi: Abi) => { const d = decodeFunctionData({ abi, data }); return { name: d.functionName, args: plain(d.args ?? []) }; });
|
|
126
|
+
const decodeRevert = (to: string | null, data: Hex) => first(abisFor(to), (abi: Abi) => { const d = decodeErrorResult({ abi, data }); return { name: d.errorName, args: plain(d.args ?? []) }; });
|
|
127
|
+
// events: every candidate with the log's topic0 is tried (ERC-20 and ERC-721 Transfer share it and differ in indexed params)
|
|
128
|
+
const decodeLog = (l: { address: string; topics: Hex[]; data: Hex }) => first(abisFor(l.address), (abi: Abi) => first(abi.filter((i) => i.type === 'event' && toEventSelector(i as any) === l.topics[0]), (item: any) => { const d: any = decodeEventLog({ abi: [item], data: l.data, topics: l.topics as [Hex, ...Hex[]] }); return { name: d.eventName as string, args: plain(d.args ?? {}) }; }));
|
|
129
|
+
sim.addMethod('terrarium_transactions', (opts?: { limit?: number; before?: string }) => {
|
|
130
|
+
const { total, transactions } = sim.transactions(opts ?? {});
|
|
131
|
+
return { total, labels: labelsOf(), transactions: transactions.map((t: any) => {
|
|
132
|
+
const hasData = t.input && t.input.length >= 10;
|
|
133
|
+
const method = !t.to ? { name: 'create', args: [] } : hasData ? decodeCall(t.to, t.input) ?? { name: null, selector: t.input.slice(0, 10) } : null;
|
|
134
|
+
const revert = t.status === 'reverted' && t.revertData && t.revertData !== '0x' ? decodeRevert(t.to, t.revertData) : null;
|
|
135
|
+
const logs = (t.receipt?.logs ?? []).map((l: any) => ({ ...l, decoded: decodeLog(l) }));
|
|
136
|
+
return { ...t, method, revert, logs };
|
|
137
|
+
}) };
|
|
138
|
+
});
|
|
139
|
+
// reset wipes this scenario's chain (its key and its actors flag); other scenarios and the selection stay
|
|
140
|
+
sim.addMethod('terrarium_reset', async () => { await actors.toggle(false); sim.stop(); if (storage && key) { await storage.removeItem(key); await storage.removeItem(actorsKey); } return true; });
|
|
141
|
+
// several scenarios: the list for the selector, and the switch (stored, then the page reloads and boots the chosen one)
|
|
142
|
+
sim.addMethod('terrarium_scenarios', () => ({ active: scenarioName, scenarios: list.map((s, i) => ({ name: names[i], description: s.description ?? null, persist: s.persist === false ? null : (s.persist ?? (list.length > 1 ? slug(names[i]) : 'default')) })) }));
|
|
143
|
+
sim.addMethod('terrarium_selectScenario', async (name: string) => {
|
|
144
|
+
if (!names.includes(name)) throw new Error(`no scenario named ${JSON.stringify(name)}; have: ${names.join(', ')}`);
|
|
145
|
+
if (name !== scenarioName) { await store?.setItem(SELECTION_KEY, name); await actors.toggle(false); sim.stop(); ctx.reload(); }
|
|
146
|
+
return name;
|
|
147
|
+
});
|
|
65
148
|
for (const [name, fn] of Object.entries(config.methods ?? {})) sim.addMethod(name, (...args: any[]) => fn(ctx, ...args));
|
|
66
149
|
|
|
67
150
|
// ---- HTTP routes: the page's fetch forwards matching requests here; the handler answers from the chain --------------
|