@terrariumlabs/core 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terrariumlabs/core",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "A complete EVM chain inside the page, presented to your dapp as a wallet. revm in WebAssembly, real receipts, byte-identical to Anvil.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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.5.0"
64
+ "@terrariumlabs/evm": "^0.6.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. 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.
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 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; }
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 .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); }
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 6px; border-radius: 6px; font: inherit; }
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> · chain <span data-f="chain">…</span><span data-f="engine"></span></span>`);
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 { snap = await rpc('evm_snapshot'); bSnap.textContent = 'Revert to snapshot'; bSnap.classList.add('on'); }
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
- 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);
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', 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`; };
161
+ let lastRender = '', filter: 'all' | 'mine' | 'failed' = 'all', search = '', me: string | null = null; // `me`: accounts[0] from terrarium_status
162
+ const bTxs = btn('Transactions', 'txs', 'Every transaction on this chain, like a block explorer: receipt, decoded call, events, revert reason (Alt+Shift+X)', () => togglePanel());
163
+ const togglePanel = async (show: boolean = !!panel.hidden) => { panel.hidden = !show; bTxs.classList.toggle('on', show); lastRender = ''; if (show) { await refreshTxs(); panel.querySelector<HTMLInputElement>('[data-testid=tx-search]')?.focus(); } place(); };
164
+ const place = () => { panel.style.bottom = `${bar.offsetHeight}px`; toasts.style.bottom = `${bar.offsetHeight + (panel.hidden ? 0 : panel.offsetHeight) + 12}px`; }; // toasts sit above whatever is open
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
- 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>`);
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 row = (e.target as HTMLElement).closest<HTMLElement>('tr.tx'); if (!row) return;
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
- const bReset = btn('Reset', 'reset', 'Wipe this scenario\'s chain and boot it again from scratch', async () => { await rpc('terrarium_reset'); location.reload(); });
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 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));
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
- bar.hidden = startHidden; pill.hidden = !startHidden; document.body.style.paddingBottom = startHidden ? '' : '56px'; // apply without remembering: the default is not a choice
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,41 @@ 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(parseInt(s.block, 16));
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.length ? ' · ' + notes.join(' · ') : '';
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
+ 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
287
  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 ?? [])))); }
288
+ if (ck !== controlsKey) { controlsKey = ck; controls.replaceChildren(...(s.controls ?? []).map((c: any, i: number) => btn(c.label, `control-${i}`, c.title ?? c.method, async () => { const r = await rpc(c.method, c.params ?? []); toast(`${c.label}${r !== undefined && r !== null && typeof r !== 'object' ? ` → ${r}` : ''}`); }))); }
219
289
  bActors.hidden = !s.hasActors; bActors.textContent = `${s.actorsLabel} ${s.actors ? 'on' : 'off'}`; bActors.classList.toggle('on', s.actors);
220
290
  gScenario.hidden = !s.hasActors && !(s.controls?.length);
221
291
  bReject.textContent = s.wallet.rejectNext > 0 ? `Reject next tx · armed (${s.wallet.rejectNext})` : 'Reject next tx'; bReject.classList.toggle('armed', s.wallet.rejectNext > 0);
222
292
  bLatency.textContent = s.wallet.latencyMs ? `Wallet: ${s.wallet.latencyMs / 1000}s delay` : 'Wallet: instant'; bLatency.classList.toggle('on', !!s.wallet.latencyMs);
223
293
  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(); }
294
+ if (!bar.hidden) place();
295
+ if (!panel.hidden) await refreshTxs();
225
296
  };
226
297
  refresh(); refreshScenarios();
227
298
  let ticks = 0;
228
299
  const timer = setInterval(() => { if (!document.getElementById('terrarium-devbar')) return clearInterval(timer); refresh(); if (++ticks % 10 === 0) refreshScenarios(); }, 500);
229
300
  }
230
301
 
231
- /** Remove the dev bar, its explorer and the show pill; restore the page's bottom padding. Idempotent. */
302
+ /** Remove the dev bar, its explorer, the toasts and the show pill; restore the page's bottom padding. Idempotent. */
232
303
  export function unmountDevBar() {
233
304
  document.getElementById('terrarium-devbar')?.remove();
234
305
  document.getElementById('terrarium-devbar-show')?.remove();
306
+ document.getElementById('terrarium-toast')?.remove();
307
+ document.getElementById('terrarium-devbar-menu')?.remove();
235
308
  document.body?.style.removeProperty('padding-bottom');
236
309
  }
package/src/engine.js CHANGED
@@ -458,7 +458,7 @@ export async function createTerrarium(opts = {}) {
458
458
  return { ...t.rpc, status, receipt: t.receipt, timestamp: b ? hex(b.timestamp) : null, error: t.error ?? null, revertData: t.revertData ?? null };
459
459
  });
460
460
  const from = before ? all.findIndex((t) => t.hash === before) + 1 : 0;
461
- return { total: all.length, transactions: all.slice(from, from + Math.max(0, limit)) };
461
+ return { total: all.length, pending: all.filter((t) => t.status === 'pending').length, failed: all.filter((t) => t.status === 'reverted' || t.status === 'dropped').length, transactions: all.slice(from, from + Math.max(0, limit)) };
462
462
  }
463
463
 
464
464
  // ---- RPC formatting ----------------------------------------------------------------------------
@@ -810,7 +810,7 @@ export async function createTerrarium(opts = {}) {
810
810
  dumpState: () => exclusive(dumpState), loadState: (d) => exclusive(() => loadState(d)), replayJournal: (j) => exclusive(() => replayJournal(j)),
811
811
  get journal() { return journal.slice(); }, flush: () => { clearTimeout(persistTimer); return persister ? exclusive(async () => persister.setItem(persistKey, JSON.stringify(await dumpState()))) : Promise.resolve(); },
812
812
  get blockNumber() { return latest().number; },
813
- /** transactions newest first ({ total, transactions }): tx + receipt + status word + revert reason/data + block timestamp */
813
+ /** transactions newest first ({ total, pending, failed, transactions }): tx + receipt + status word + revert reason/data + block timestamp */
814
814
  transactions: (o) => listTransactions(o),
815
815
  /** React to on-chain events with scripted actors (keepers, oracles, other users, bridges...). */
816
816
  onLog(filter, handler) { const l = { filter, handler }; logListeners.push(l); return () => logListeners.splice(logListeners.indexOf(l), 1); },
@@ -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;