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