@terrariumlabs/core 0.3.0 → 0.4.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.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "A complete EVM chain inside the page, presented to your dapp as a wallet. revm in WebAssembly, real receipts, byte-identical to Anvil.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,7 +33,7 @@
33
33
  "access": "public"
34
34
  },
35
35
  "bin": {
36
- "terrarium": "./bin/terrarium.mjs"
36
+ "terrarium": "bin/terrarium.mjs"
37
37
  },
38
38
  "exports": {
39
39
  ".": "./src/engine.js",
@@ -60,7 +60,7 @@
60
60
  "@ethereumjs/util": "^10.1.3",
61
61
  "@ethereumjs/mpt": "^10.1.3",
62
62
  "@ethereumjs/rlp": "^10.0.0",
63
- "@terrariumlabs/evm": "^0.3.0"
63
+ "@terrariumlabs/evm": "^0.4.0"
64
64
  },
65
65
  "peerDependencies": {
66
66
  "viem": "^2.0.0",
package/src/devbar.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  // devbar.ts — the dev overlay. Plain DOM, own styles, talks to the chain only through provider.request(), so it works
2
- // on top of any dapp (React or not) and never touches the dapp's code.
2
+ // on top of any dapp (React or not) and never touches the dapp's code. Two parts: the bar (controls) and the transaction
3
+ // explorer, a panel above it listing every transaction with its receipt, decoded call, events and revert reason
4
+ // (`terrarium_transactions`: the scenario runtime decodes with the scenario's `abis` and names addresses with `labels`).
3
5
  type Provider = { request(a: { method: string; params?: unknown[] }): Promise<any> };
4
6
 
7
+ const HIDDEN_KEY = 'terrarium:devbar-hidden';
5
8
  const CSS = `
6
9
  #terrarium-devbar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 2147483000; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 10px 24px; background: #14231b; color: #dfe9e3; font: 13px/1.4 ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; font-variant-numeric: tabular-nums; }
10
+ #terrarium-devbar[hidden], #terrarium-explorer[hidden], #terrarium-devbar-show[hidden] { display: none; }
7
11
  #terrarium-devbar .tag { background: #e8c547; color: #14231b; font-weight: 700; padding: 2px 8px; border-radius: 6px; }
8
12
  #terrarium-devbar .muted { color: rgba(223, 233, 227, 0.7); }
9
13
  #terrarium-devbar .muted b { color: #fff; font-weight: 600; }
@@ -13,8 +17,45 @@ const CSS = `
13
17
  #terrarium-devbar button.on { background: #1f6f5c; border-color: #1f6f5c; }
14
18
  #terrarium-devbar button.armed { background: #7a3b2a; border-color: #b3452c; }
15
19
  #terrarium-devbar button.danger { border-color: rgba(255,140,110,0.4); color: #ffb5a0; }
20
+ #terrarium-devbar button.quiet { background: transparent; border-color: transparent; color: rgba(223,233,227,0.7); }
21
+ #terrarium-devbar-show { position: fixed; right: 12px; bottom: 12px; z-index: 2147483000; width: 36px; height: 36px; border-radius: 50%; border: 1px solid rgba(255,255,255,0.2); background: #14231b; color: #e8c547; font: 16px/1 ui-sans-serif, system-ui, sans-serif; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.35); }
22
+ #terrarium-explorer { position: fixed; left: 0; right: 0; z-index: 2147483000; max-height: 60vh; overflow: auto; background: #0f1a14; color: #dfe9e3; border-top: 1px solid rgba(255,255,255,0.12); font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-variant-numeric: tabular-nums; }
23
+ #terrarium-explorer .head { display: flex; align-items: center; gap: 12px; padding: 8px 24px; font: 13px ui-sans-serif, system-ui, sans-serif; color: rgba(223,233,227,0.8); position: sticky; top: 0; background: #0f1a14; border-bottom: 1px solid rgba(255,255,255,0.08); }
24
+ #terrarium-explorer .head b { color: #fff; }
25
+ #terrarium-explorer table { width: 100%; border-collapse: collapse; }
26
+ #terrarium-explorer th { text-align: left; font-weight: 600; color: rgba(223,233,227,0.6); padding: 6px 12px; border-bottom: 1px solid rgba(255,255,255,0.08); white-space: nowrap; }
27
+ #terrarium-explorer th:first-child, #terrarium-explorer td:first-child { padding-left: 24px; }
28
+ #terrarium-explorer td { padding: 6px 12px; border-bottom: 1px solid rgba(255,255,255,0.05); white-space: nowrap; vertical-align: top; }
29
+ #terrarium-explorer tr.tx { cursor: pointer; }
30
+ #terrarium-explorer tr.tx:hover td { background: rgba(255,255,255,0.04); }
31
+ #terrarium-explorer tr.tx.open td { background: rgba(255,255,255,0.06); border-bottom-color: transparent; }
32
+ #terrarium-explorer .ok { color: #7fd3a8; } #terrarium-explorer .bad { color: #ff9c80; } #terrarium-explorer .wait { color: #e8c547; }
33
+ #terrarium-explorer .hash, #terrarium-explorer .addr { color: #9ecbff; }
34
+ #terrarium-explorer .name { color: #e8c547; }
35
+ #terrarium-explorer .dim { color: rgba(223,233,227,0.55); }
36
+ #terrarium-explorer .detail td { padding: 10px 24px 14px 48px; white-space: normal; background: rgba(0,0,0,0.25); }
37
+ #terrarium-explorer dl { display: grid; grid-template-columns: max-content 1fr; gap: 3px 16px; margin: 0; }
38
+ #terrarium-explorer dt { color: rgba(223,233,227,0.6); } #terrarium-explorer dd { margin: 0; word-break: break-all; }
39
+ #terrarium-explorer .events { margin: 10px 0 0; padding: 0; list-style: none; }
40
+ #terrarium-explorer .events li { padding: 6px 0 6px 10px; border-left: 2px solid rgba(232,197,71,0.5); margin-bottom: 4px; word-break: break-all; }
41
+ #terrarium-explorer .args { color: #dfe9e3; }
42
+ #terrarium-explorer .empty { padding: 24px; text-align: center; color: rgba(223,233,227,0.55); font: 13px ui-sans-serif, system-ui, sans-serif; }
16
43
  `;
17
44
 
45
+ const esc = (s: unknown) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]!);
46
+ const short = (h: string) => (h && h.length > 14 ? `${h.slice(0, 8)}…${h.slice(-6)}` : h);
47
+ const num = (hex: string | null | undefined) => (hex ? BigInt(hex).toString() : '');
48
+ /** wei (hex) as ETH with up to 6 decimals, trailing zeros trimmed */
49
+ export const formatEth = (hex: string | null | undefined) => {
50
+ if (!hex) return '0';
51
+ const wei = BigInt(hex), whole = wei / 10n ** 18n, frac = (wei % 10n ** 18n).toString().padStart(18, '0').slice(0, 6).replace(/0+$/, '');
52
+ return frac ? `${whole}.${frac}` : whole.toString();
53
+ };
54
+ /** wei (hex) as gwei with up to 3 decimals */
55
+ const formatGwei = (hex: string | null | undefined) => { if (!hex) return '0'; const wei = BigInt(hex), whole = wei / 10n ** 9n, frac = (wei % 10n ** 9n).toString().padStart(9, '0').slice(0, 3).replace(/0+$/, ''); return frac ? `${whole}.${frac}` : whole.toString(); };
56
+ const fmtArgs = (args: any): string => Array.isArray(args) ? args.map(fmtArgs).join(', ') : args && typeof args === 'object' ? Object.entries(args).map(([k, v]) => `${k}: ${fmtArgs(v)}`).join(', ') : String(args);
57
+ const time = (hex: string | null) => (hex ? new Date(Number(BigInt(hex)) * 1000).toLocaleTimeString() : '');
58
+
18
59
  export function mountDevBar(provider: Provider) {
19
60
  if (document.getElementById('terrarium-devbar')) return;
20
61
  const rpc = (method: string, params: unknown[] = []) => provider.request({ method, params });
@@ -43,15 +84,77 @@ export function mountDevBar(provider: Provider) {
43
84
  const bReset = btn('Reset pond', 'reset', 'Wipe the chain and redeploy everything', async () => { await rpc('terrarium_reset'); location.reload(); });
44
85
  bReset.classList.add('danger');
45
86
 
87
+ // ---- the transaction explorer -----------------------------------------------------------------------------------
88
+ const panel = el(`<section id="terrarium-explorer" data-testid="explorer" hidden></section>`);
89
+ const open = new Set<string>(); // expanded rows, by hash, kept across refreshes
90
+ let lastRender = '';
91
+ const bTxs = btn('Transactions', 'txs', 'Every transaction on this chain, like a block explorer: receipt, decoded call, events, revert reason', async () => {
92
+ panel.hidden = !panel.hidden; bTxs.classList.toggle('on', !panel.hidden); lastRender = '';
93
+ if (!panel.hidden) await refreshTxs();
94
+ });
95
+ const place = () => { panel.style.bottom = `${bar.offsetHeight}px`; };
96
+ const statusCell = (s: string) => s === 'success' ? '<span class="ok" title="success">✓</span>' : s === 'pending' ? '<span class="wait" title="pending">⏳</span>' : `<span class="bad" title="${s}">✗</span>`;
97
+ const who = (labels: Record<string, string>, a: string | null) => a ? `<span class="addr" title="${a}">${esc(labels[a.toLowerCase()] ?? short(a))}</span>` : '<span class="dim">contract creation</span>';
98
+ const render = (data: { total: number; labels: Record<string, string>; transactions: any[] }) => {
99
+ const key = JSON.stringify([data.total, data.transactions.map((t) => [t.hash, t.status]), [...open]]);
100
+ if (key === lastRender) return; lastRender = key;
101
+ const L = data.labels ?? {};
102
+ const rows = data.transactions.map((t) => {
103
+ const method = t.method ? (t.method.name ? `<span class="name">${esc(t.method.name)}</span>` : `<span class="dim">${esc(t.method.selector)}</span>`) : '<span class="dim">transfer</span>';
104
+ const isOpen = open.has(t.hash);
105
+ const detail = !isOpen ? '' : `<tr class="detail" data-testid="tx-detail"><td colspan="10"><dl>
106
+ <dt>hash</dt><dd class="hash">${esc(t.hash)}</dd>
107
+ <dt>status</dt><dd>${esc(t.status)}${t.status === 'reverted' || t.status === 'dropped' ? ` <span class="bad">· ${t.revert ? esc(t.revert.name) + '(' + esc(fmtArgs(t.revert.args)) + ')' : esc(t.error ?? '')}</span>` : ''}</dd>
108
+ <dt>block</dt><dd>${t.receipt ? `${num(t.receipt.blockNumber)} · ${esc(time(t.timestamp))} · index ${num(t.receipt.transactionIndex)}` : 'pending'}</dd>
109
+ <dt>from</dt><dd>${who(L, t.from)} <span class="dim">${esc(t.from)}</span></dd>
110
+ <dt>to</dt><dd>${t.to ? `${who(L, t.to)} <span class="dim">${esc(t.to)}</span>` : `<span class="dim">created</span> ${esc(t.receipt?.contractAddress ?? '')}`}</dd>
111
+ <dt>value</dt><dd>${formatEth(t.value)} ETH</dd>
112
+ <dt>nonce</dt><dd>${num(t.nonce)}</dd>
113
+ <dt>gas</dt><dd>${t.receipt ? `${num(t.receipt.gasUsed)} used` : ''} of ${num(t.gas)} limit${t.receipt ? ` · ${esc(formatGwei(t.receipt.effectiveGasPrice))} gwei/gas · fee ${esc(formatEth('0x' + (BigInt(t.receipt.gasUsed) * BigInt(t.receipt.effectiveGasPrice)).toString(16)))} ETH` : ''}</dd>
114
+ <dt>input</dt><dd>${t.method?.name ? `<span class="name">${esc(t.method.name)}</span>(<span class="args">${esc(fmtArgs(t.method.args))}</span>)<br>` : ''}<span class="dim">${esc(t.input === '0x' ? '0x (no data)' : t.input)}</span></dd>
115
+ ${t.revertData && t.revertData !== '0x' ? `<dt>revert data</dt><dd class="dim">${esc(t.revertData)}</dd>` : ''}
116
+ </dl>
117
+ ${t.logs?.length ? `<ul class="events" data-testid="tx-events">${t.logs.map((l: any) => `<li><span class="dim">#${num(l.logIndex)}</span> ${who(L, l.address)} ${l.decoded ? `<span class="name">${esc(l.decoded.name)}</span>(<span class="args">${esc(fmtArgs(l.decoded.args))}</span>)` : `<span class="dim">topics ${l.topics.map(esc).join(' ')}<br>data ${esc(l.data)}</span>`}</li>`).join('')}</ul>` : `<div class="dim" style="margin-top:8px">no events</div>`}
118
+ </td></tr>`;
119
+ return `<tr class="tx${isOpen ? ' open' : ''}" data-testid="tx-row" data-hash="${t.hash}" data-status="${t.status}">
120
+ <td>${statusCell(t.status)}</td><td>${t.receipt ? num(t.receipt.blockNumber) : '<span class="dim">pending</span>'}</td><td class="dim">${esc(time(t.timestamp))}</td>
121
+ <td class="hash" title="${t.hash}">${short(t.hash)}</td><td>${method}</td><td>${who(L, t.from)}</td><td>${who(L, t.to)}</td>
122
+ <td>${formatEth(t.value)} ETH</td><td class="dim">${t.receipt ? num(t.receipt.gasUsed) : ''}</td><td class="dim">${t.logs?.length ? `${t.logs.length} event${t.logs.length === 1 ? '' : 's'}` : ''}</td></tr>${detail}`;
123
+ }).join('');
124
+ panel.innerHTML = `<div class="head"><b>Transactions</b> <span>${data.total} on this chain, newest first${data.total > data.transactions.length ? ` (showing ${data.transactions.length})` : ''}</span><span class="spacer" style="flex:1"></span><span class="dim">click a row for the receipt, the decoded call and the events</span></div>`
125
+ + (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">No transactions yet. Do something in the dapp and it appears here.</div>`);
126
+ place();
127
+ };
128
+ panel.addEventListener('click', (e) => {
129
+ const row = (e.target as HTMLElement).closest<HTMLElement>('tr.tx'); if (!row) return;
130
+ const h = row.dataset.hash!; open.has(h) ? open.delete(h) : open.add(h); lastRender = ''; refreshTxs();
131
+ });
132
+ const refreshTxs = async () => { const data = await rpc('terrarium_transactions', [{ limit: 200 }]).catch(() => null); if (data) render(data); };
133
+
134
+ // ---- hide / show --------------------------------------------------------------------------------------------------
135
+ const pill = el(`<button id="terrarium-devbar-show" data-testid="show" title="Show the Terrarium dev bar" hidden>🌱</button>`);
136
+ const remember = (hidden: boolean) => { try { hidden ? localStorage.setItem(HIDDEN_KEY, '1') : localStorage.removeItem(HIDDEN_KEY); } catch {} };
137
+ const setHidden = (hidden: boolean) => {
138
+ bar.hidden = hidden; pill.hidden = !hidden;
139
+ if (hidden) { panel.hidden = true; bTxs.classList.remove('on'); }
140
+ document.body.style.paddingBottom = hidden ? '' : '64px';
141
+ remember(hidden);
142
+ };
143
+ const bHide = btn('Hide', 'hide', 'Hide the dev bar (the chain keeps running); the leaf at the bottom right brings it back', () => setHidden(true));
144
+ bHide.classList.add('quiet');
145
+ pill.onclick = () => setHidden(false);
146
+
46
147
  const controls = el('<span class="controls" style="display:contents"></span>'); let controlsKey = '';
47
148
  bar.append(el('<span class="tag">Terrarium</span>'), info, el('<span class="spacer"></span>'), controls,
48
149
  btn('Mine a block', 'mine', 'Mine one empty block', () => rpc('evm_mine')),
49
150
  btn('+1 hour', 'plus-hour', 'Move the chain clock forward one hour', async () => { await rpc('evm_increaseTime', [3600]); await rpc('evm_mine'); }),
50
- bMining, bSnap, bActors, bReject, bLatency, bLag, bReset);
51
- document.body.append(bar);
52
- document.body.style.paddingBottom = '64px';
151
+ bMining, bSnap, bActors, bReject, bLatency, bLag, bTxs, bReset, bHide, panel);
152
+ document.body.append(bar, pill);
153
+ let startHidden = false; try { startHidden = localStorage.getItem(HIDDEN_KEY) === '1'; } catch {}
154
+ setHidden(startHidden);
53
155
 
54
156
  const refresh = async () => {
157
+ if (!document.getElementById('terrarium-devbar')) return; // unmounted: stop polling
55
158
  const s = await rpc('terrarium_status').catch(() => null); if (!s) return;
56
159
  info.querySelector('[data-f=chain]')!.textContent = String(s.chainId);
57
160
  info.querySelector('[data-f=block]')!.textContent = String(parseInt(s.block, 16));
@@ -64,6 +167,14 @@ export function mountDevBar(provider: Provider) {
64
167
  bReject.textContent = s.wallet.rejectNext > 0 ? `Reject next tx · armed (${s.wallet.rejectNext})` : 'Reject next tx'; bReject.classList.toggle('armed', s.wallet.rejectNext > 0);
65
168
  bLatency.textContent = s.wallet.latencyMs ? `Wallet: ${s.wallet.latencyMs / 1000}s delay` : 'Wallet: instant'; bLatency.classList.toggle('on', !!s.wallet.latencyMs);
66
169
  bLag.textContent = s.wallet.receiptLagMs ? `Receipts: ${s.wallet.receiptLagMs / 1000}s late` : 'Receipts: instant'; bLag.classList.toggle('on', !!s.wallet.receiptLagMs);
170
+ if (!panel.hidden) await refreshTxs();
67
171
  };
68
- refresh(); setInterval(refresh, 500);
172
+ refresh(); const timer = setInterval(() => { if (document.getElementById('terrarium-devbar')) refresh(); else clearInterval(timer); }, 500);
173
+ }
174
+
175
+ /** Remove the dev bar, its explorer and the show pill; restore the page's bottom padding. Idempotent. */
176
+ export function unmountDevBar() {
177
+ document.getElementById('terrarium-devbar')?.remove();
178
+ document.getElementById('terrarium-devbar-show')?.remove();
179
+ document.body?.style.removeProperty('padding-bottom');
69
180
  }
package/src/engine.js CHANGED
@@ -364,11 +364,12 @@ export async function createTerrarium(opts = {}) {
364
364
  r = await exec({ tx: entry.tx, block, flags: { blockGasUsed: cumulative } });
365
365
  } catch (e) { // invalid tx (nonce too low, insufficient funds...): a node would drop it silently and the dapp
366
366
  // would wait for a receipt forever. Record a failed receipt instead so waiters resolve and the reason is visible.
367
- const t = txs.get(entry.hash); t.error = String(e.message);
367
+ const t = txs.get(entry.hash); t.error = String(e.message); t.dropped = true;
368
368
  receipts.push({ transactionHash: entry.hash, transactionIndex: hex(idx), from: entry.from, to: entry.rpc.to, cumulativeGasUsed: hex(cumulative), gasUsed: '0x0', effectiveGasPrice: hex(baseFee), contractAddress: null, logs: [], logsBloom: '0x' + '00'.repeat(256), status: '0x0', type: '0x2', droppedReason: t.error });
369
369
  hashes.push(entry.hash); sealTxs.push(entry.tx); sealReceipts.push({ status: 0, cumulativeBlockGasUsed: cumulative, bitvector: new Uint8Array(256), logs: [] }); continue;
370
370
  }
371
371
  cumulative += r.gasUsed;
372
+ if (!r.success) { const t = txs.get(entry.hash); t.error = r.error ?? 'execution reverted'; t.revertData = bytesToHex(r.returnValue); }
372
373
  if (globalThis.process?.env?.TERRARIUM_DEBUG && !r.success) console.log('[tx reverted]', entry.hash, r.error, bytesToHex(r.returnValue), 'gasUsed', r.gasUsed, 'gasLimit', entry.tx.gasLimit);
373
374
  const bloom = bloomOf(r.logs);
374
375
  const logs = r.logs.map(([addr, topics, data]) => ({ address: bytesToHex(addr), topics: topics.map(bytesToHex), data: bytesToHex(data), blockNumber: hex(header.number), transactionHash: entry.hash, transactionIndex: hex(idx), logIndex: hex(logIndex++), removed: false }));
@@ -447,6 +448,19 @@ export async function createTerrarium(opts = {}) {
447
448
  return hi;
448
449
  }
449
450
 
451
+ /** Transactions newest first, as an explorer lists them: the RPC tx merged with its receipt, a `status` word, the
452
+ * revert reason and data of a failed one, and the block timestamp. Pending (interval mining) come first. */
453
+ function listTransactions({ limit = 50, before } = {}) {
454
+ const all = [...txs.values()].reverse().map((t) => {
455
+ const mined = !!t.receipt, n = mined ? hexToBigInt(t.receipt.blockNumber) : null;
456
+ const b = mined ? blocks.find((b) => BigInt(b.number) === n) : null;
457
+ const status = !mined ? 'pending' : t.receipt.status === '0x1' ? 'success' : t.dropped ? 'dropped' : 'reverted';
458
+ return { ...t.rpc, status, receipt: t.receipt, timestamp: b ? hex(b.timestamp) : null, error: t.error ?? null, revertData: t.revertData ?? null };
459
+ });
460
+ const from = before ? all.findIndex((t) => t.hash === before) + 1 : 0;
461
+ return { total: all.length, transactions: all.slice(from, from + Math.max(0, limit)) };
462
+ }
463
+
450
464
  // ---- RPC formatting ----------------------------------------------------------------------------
451
465
  function rpcBlock(b, full) {
452
466
  const transactions = full ? b.transactions.map((h) => txs.get(h).rpc) : b.transactions;
@@ -540,6 +554,7 @@ export async function createTerrarium(opts = {}) {
540
554
  case 'eth_sendRawTransaction': { const tx = createTxFromRLP(hexToBytes(params[0]), { common }); return submit(tx, tx.getSenderAddress().toString()); }
541
555
  case 'eth_getTransactionReceipt': { const t = txs.get(params[0]); if (!t?.receipt) return null; if (walletKnobs.receiptLagMs > 0 && Date.now() - (t.minedAt ?? 0) < walletKnobs.receiptLagMs) return null; return t.receipt; }
542
556
  case 'eth_getTransactionByHash': return txs.get(params[0])?.rpc ?? null;
557
+ case 'terrarium_transactions': return listTransactions(params[0] ?? {});
543
558
  case 'eth_getLogs': return getLogs(params[0] ?? {});
544
559
  case 'eth_newFilter': filters.set(hex(nextFilterId), { type: 'logs', f: params[0] ?? {}, cursor: latest().number + 1n }); return hex(nextFilterId++);
545
560
  case 'eth_newBlockFilter': filters.set(hex(nextFilterId), { type: 'blocks', cursor: latest().number + 1n }); return hex(nextFilterId++);
@@ -706,7 +721,7 @@ export async function createTerrarium(opts = {}) {
706
721
  const remote = sm.remote ? { accounts: Object.fromEntries([...sm.remote.accounts].map(([a, acct]) => [a, acct ? { nonce: hex(acct.nonce), balance: hex(acct.balance), codeHash: bytesToHex(acct.codeHash) } : null])), code: Object.fromEntries([...sm.remote.code].map(([a, c]) => [a, bytesToHex(c)])), storage: Object.fromEntries([...sm.remote.storage].map(([k, v]) => [k, bytesToHex(v)])) } : undefined;
707
722
  return { version: 1, chainId, savedAt: Date.now(), state: { accounts: accountsOut, code: codeOut, storage: storageOut }, remote,
708
723
  // tx bodies: only the most recent blocks' worth (like a pruned node), and without their logs (rebuilt from block logs on load)
709
- chain: { blocks: blocks.map(serBlock), txs: Object.fromEntries([...txs].filter(([, t]) => !t.receipt || hexToBigInt(t.receipt.blockNumber) >= keepFrom).map(([h, t]) => [h, { rpc: t.rpc, receipt: t.receipt ? { ...t.receipt, logs: undefined } : null, error: t.error }])), timeOffset: hex(timeOffset), baseFee: hex(baseFee), mining, impersonated: [...impersonated], dealtSlots: Object.fromEntries(dealtSlots) },
724
+ chain: { blocks: blocks.map(serBlock), txs: Object.fromEntries([...txs].filter(([, t]) => !t.receipt || hexToBigInt(t.receipt.blockNumber) >= keepFrom).map(([h, t]) => [h, { rpc: t.rpc, receipt: t.receipt ? { ...t.receipt, logs: undefined } : null, error: t.error, revertData: t.revertData, dropped: t.dropped }])), timeOffset: hex(timeOffset), baseFee: hex(baseFee), mining, impersonated: [...impersonated], dealtSlots: Object.fromEntries(dealtSlots) },
710
725
  journal: { entries: journal, timestamps: blocks.slice(1).map((b) => hex(b.timestamp)) } };
711
726
  }
712
727
  async function seedState({ accounts: accs = {}, code = {}, storage = {} }) {
@@ -763,6 +778,8 @@ export async function createTerrarium(opts = {}) {
763
778
  dumpState: () => exclusive(dumpState), loadState: (d) => exclusive(() => loadState(d)), replayJournal: (j) => exclusive(() => replayJournal(j)),
764
779
  get journal() { return journal.slice(); }, flush: () => { clearTimeout(persistTimer); return persister ? exclusive(async () => persister.setItem(persistKey, JSON.stringify(await dumpState()))) : Promise.resolve(); },
765
780
  get blockNumber() { return latest().number; },
781
+ /** transactions newest first ({ total, transactions }): tx + receipt + status word + revert reason/data + block timestamp */
782
+ transactions: (o) => listTransactions(o),
766
783
  /** React to on-chain events with scripted actors (keepers, oracles, other users, bridges...). */
767
784
  onLog(filter, handler) { const l = { filter, handler }; logListeners.push(l); return () => logListeners.splice(logListeners.indexOf(l), 1); },
768
785
  /** Cheat: send a tx "from" any address without keys (impersonation), e.g. to simulate another user. */
package/src/inject.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // wallet (so the dapp's own connect modal lists "Terrarium Wallet") and mount the dev bar. Nothing here is imported by
3
3
  // the dapp; this is the analogue of a browser extension's injected script.
4
4
  import { createWorkerProvider } from './bridge.ts';
5
- import { mountDevBar } from './devbar.ts';
5
+ import { mountDevBar, unmountDevBar } from './devbar.ts';
6
6
  import { installHttpInterceptor, type WireRoute } from './http.ts';
7
7
 
8
8
  const ICON = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#1F6F5C"/><path d="M6 25.5c2-6 5.5-8.5 10-8.5s8 2.5 10 8.5" fill="none" stroke="#E8C547" stroke-width="2.5" stroke-linecap="round"/><path d="M16 17.5V9" stroke="#E8C547" stroke-width="2.5" stroke-linecap="round"/><path d="M16 12c0-4.5 3-7 7-7 0 4.5-3 7-7 7Z M16 14.5c0-4.5-3-7-7-7 0 4.5 3 7 7 7Z" fill="#E8C547"/></svg>');
@@ -38,7 +38,6 @@ export function stopTerrarium() {
38
38
  window.removeEventListener('eip6963:requestProvider', current.announce);
39
39
  current.worker.terminate();
40
40
  current = null;
41
- document.getElementById('terrarium-devbar')?.remove();
42
- document.body?.style.removeProperty('padding-bottom');
41
+ unmountDevBar();
43
42
  delete (window as any).terrarium;
44
43
  }
package/src/scenario.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // actors: [{ every: 5000, run: (ctx) => ... }, { on: { address, topics }, run: (ctx, log) => ... }],
7
7
  // status: (ctx) => ({ addresses: ctx.state }),
8
8
  // });
9
- import type { Account, Address, Chain, Hex, PublicClient, TransactionReceipt, Transport, WalletClient } from 'viem';
9
+ import type { Abi, Account, Address, Chain, Hex, PublicClient, TransactionReceipt, Transport, WalletClient } from 'viem';
10
10
  import type { HttpRoute } from './http.ts';
11
11
  export { reply } from './http.ts';
12
12
  export type { HttpRoute, HttpRequest, HttpReply, GraphqlQuery, GraphqlResolver } from './http.ts';
@@ -89,6 +89,12 @@ export interface ScenarioConfig {
89
89
  * intercepted for matching URLs, the handler runs here in the Worker with `ctx`, everything else goes to the network.
90
90
  * http: [{ match: 'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v2', graphql: { swaps: (ctx, q) => … } }] */
91
91
  http?: HttpRoute[];
92
+ /** ABIs used to decode calls, events and revert reasons in `terrarium_transactions` (the dev bar's transaction explorer);
93
+ * anything they do not cover is shown raw (selector, topics, data) */
94
+ abis?: Abi[];
95
+ /** names for addresses in the explorer (`{ [address]: 'Uniswap V2 Router' }`), or a function of ctx for addresses only
96
+ * known after setup. The sim's accounts are labelled `Account #i` unless you name them */
97
+ labels?: Record<string, string> | ((ctx: ScenarioContext) => Record<string, string>);
92
98
  }
93
99
 
94
100
  export function defineScenario(config: ScenarioConfig): ScenarioConfig { return config; }
@@ -1,6 +1,6 @@
1
1
  // worker-runtime.ts — runs a scenario inside the Worker: boot the chain, run setup(), wire the actors, expose the
2
2
  // generic terrarium_* controls, and serve the provider to the page over postMessage.
3
- import { createPublicClient, createWalletClient, custom, defineChain, toHex, type Address, type Hex } from 'viem';
3
+ import { createPublicClient, createWalletClient, custom, defineChain, decodeErrorResult, decodeEventLog, decodeFunctionData, toHex, type Abi, type Address, type Hex } from 'viem';
4
4
  // @ts-ignore — the engine is plain ESM JavaScript
5
5
  import { createTerrarium, indexedDBStorage } from './engine.js';
6
6
  import { serveProvider } from './bridge.ts';
@@ -61,6 +61,28 @@ export async function runScenario(config: ScenarioConfig) {
61
61
  // ---- generic controls, reachable through the provider like any RPC method -----------------------------------
62
62
  sim.addMethod('terrarium_actors', async (on?: boolean) => { await actors.toggle(on ?? !actors.enabled); return actors.enabled; });
63
63
  sim.addMethod('terrarium_status', async () => ({ chainId, engine: sim.engine, block: toHex(sim.blockNumber), accounts: ctx.accounts, actors: actors.enabled, actorsLabel: config.actorsLabel ?? 'Actors', hasActors: (config.actors?.length ?? 0) > 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)) }));
64
+ // ---- the transaction explorer: the engine's list, decoded with the scenario's ABIs and labelled -------------------
65
+ const abi = (config.abis ?? []).flat() as Abi;
66
+ const labelsOf = () => {
67
+ const out: Record<string, string> = {};
68
+ ctx.accounts.forEach((a, i) => { out[a.toLowerCase()] = `Account #${i}`; });
69
+ const own = typeof config.labels === 'function' ? config.labels(ctx) : config.labels ?? {};
70
+ for (const [a, name] of Object.entries(own)) if (a) out[a.toLowerCase()] = name;
71
+ return out;
72
+ };
73
+ const plain = (v: any): any => typeof v === 'bigint' ? v.toString() : Array.isArray(v) ? v.map(plain) : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, plain(x)])) : v;
74
+ const attempt = <T,>(fn: () => T): T | null => { try { return fn(); } catch { return null; } };
75
+ sim.addMethod('terrarium_transactions', (opts?: { limit?: number; before?: string }) => {
76
+ const { total, transactions } = sim.transactions(opts ?? {});
77
+ const labels = labelsOf();
78
+ return { total, labels, transactions: transactions.map((t: any) => {
79
+ const call = t.to && t.input && t.input.length >= 10 && abi.length ? attempt(() => decodeFunctionData({ abi, data: t.input })) : null;
80
+ const method = !t.to ? { name: 'create', args: [] } : call ? { name: call.functionName, args: plain(call.args ?? []) } : t.input && t.input.length >= 10 ? { name: null, selector: t.input.slice(0, 10) } : null;
81
+ const revert = t.status === 'reverted' && t.revertData && t.revertData !== '0x' ? attempt(() => { const d = decodeErrorResult({ abi, data: t.revertData }); return { name: d.errorName, args: plain(d.args ?? []) }; }) : null;
82
+ const logs = (t.receipt?.logs ?? []).map((l: any) => { const d = abi.length ? attempt(() => decodeEventLog({ abi, data: l.data, topics: l.topics })) : null; return { ...l, decoded: d ? { name: d.eventName, args: plain(d.args ?? {}) } : null }; });
83
+ return { ...t, method, revert, logs };
84
+ }) };
85
+ });
64
86
  sim.addMethod('terrarium_reset', async () => { await actors.toggle(false); sim.stop(); await storage?.clear(); return true; });
65
87
  for (const [name, fn] of Object.entries(config.methods ?? {})) sim.addMethod(name, (...args: any[]) => fn(ctx, ...args));
66
88