+
+
+
-
+
+ Aggregates total fees, compute gas, and message-tree metrics from the transactions executed since the previous capture.
+
{error ? (
-
+
{error instanceof Error ? error.message : String(error)}
- ) : metrics ? (
- metrics.length === 0 ? (
-
- ) : (
-
-
-
-
- {regressions}
- {regressions > 0 ? (
- attention
- ) : (
- clean
- )}
-
- }
- hint="Metrics flagged worse than baseline."
- />
- m.deltaPct < 0 && !m.regression).length}
- hint="Metrics moved in a good direction."
- />
-
-
-
- )
- ) : (
-
- )}
+ ) : null}
);
}
@@ -171,7 +244,7 @@ function Picker({
>
{snapshots.map((s) => (
))}
diff --git a/app/src/rpc/client.ts b/app/src/rpc/client.ts
index 0da2350..f7133b8 100644
--- a/app/src/rpc/client.ts
+++ b/app/src/rpc/client.ts
@@ -113,6 +113,7 @@ export class RpcClient {
merge: (workspaceId: string, runs: string[]) => this.call(METHODS.coverage.merge, { workspaceId, runs })
};
bench = {
+ capture: (workspaceId: string, name: string) => this.call(METHODS.bench.capture, { workspaceId, name }),
listSnapshots: (workspaceId: string, dir?: string) => this.call(METHODS.bench.listSnapshots, { workspaceId, dir }),
compare: (workspaceId: string, runId: string, baselineId: string) => this.call(METHODS.bench.compare, { workspaceId, runId, baselineId })
};
diff --git a/app/src/rpc/contract.ts b/app/src/rpc/contract.ts
index e1315f9..a6d8cdf 100644
--- a/app/src/rpc/contract.ts
+++ b/app/src/rpc/contract.ts
@@ -66,6 +66,7 @@ export const METHODS = {
merge: 'coverage.merge'
},
bench: {
+ capture: 'bench.capture',
listSnapshots: 'bench.listSnapshots',
compare: 'bench.compare'
},
@@ -321,7 +322,8 @@ export type MethodMap = {
'coverage.enable': { req: { workspaceId: string }; res: { ok: boolean } };
'coverage.get': { req: { workspaceId: string; contract: string }; res: { source: 'func' | 'tact'; sourcePath: string; lines: { line: number; hits: number; opcodes?: number }[] } };
'coverage.merge': { req: { workspaceId: string; runs: string[] }; res: { source: 'func' | 'tact'; sourcePath: string; lines: { line: number; hits: number; opcodes?: number }[] } };
- 'bench.listSnapshots': { req: { workspaceId: string; dir?: string }; res: { snapshots: { id: string; path: string; createdAt: number }[] } };
+ 'bench.capture': { req: { workspaceId: string; name: string }; res: { id: string; path: string; createdAt: number } };
+ 'bench.listSnapshots': { req: { workspaceId: string; dir?: string }; res: { snapshots: { id: string; name: string; path: string; createdAt: number }[] } };
'bench.compare': { req: { workspaceId: string; runId: string; baselineId: string }; res: { metrics: { name: string; current: string; baseline: string; deltaPct: number; regression: boolean }[] } };
'step.begin': { req: { workspaceId: string; message: SerializedMessage }; res: { iterId: string; done: boolean } };
'step.next': { req: { iterId: string }; res: { node: TxNode | null; done: boolean } };
diff --git a/packages/bridge/handlers.ts b/packages/bridge/handlers.ts
index 8b99594..6d110ad 100644
--- a/packages/bridge/handlers.ts
+++ b/packages/bridge/handlers.ts
@@ -22,9 +22,9 @@ import {
snapshotToSerializable,
type CoverageData
} from '@ton/sandbox';
-import { getWorkspace, listWorkspaces, recordTransactions, type Workspace } from './state.js';
+import { getWorkspace, listWorkspaces, recordTransactions, type BenchRun, type Workspace } from './state.js';
import { addrToWire, big, cellToWire, tupleItemToWire, wireToCell, wireToTupleItem } from './encode.js';
-import { exitName, toTxNode, txId, txSummary } from './tree.js';
+import { exitName, toTxNode, txSummary } from './tree.js';
export interface Conn {
notify: (method: string, params: unknown) => void;
@@ -650,8 +650,37 @@ export const HANDLERS: Record
= {
},
// ── bench ──
- 'bench.listSnapshots': async () => ({ snapshots: [] }),
- 'bench.compare': async () => ({ metrics: [] }),
+ 'bench.capture': async (p) => {
+ const ws = await getWorkspace(p.workspaceId);
+ const run = captureBench(ws, p.name);
+ return { id: run.id, path: run.path, createdAt: run.createdAt };
+ },
+ 'bench.listSnapshots': async (p) => {
+ const ws = await getWorkspace(p.workspaceId);
+ // Newest first so the panel defaults to comparing the latest run to the one before.
+ const snapshots = [...ws.benchRuns.values()]
+ .reverse()
+ .map((r) => ({ id: r.id, name: r.name, path: r.path, createdAt: r.createdAt }));
+ return { snapshots };
+ },
+ 'bench.compare': async (p) => {
+ const ws = await getWorkspace(p.workspaceId);
+ const run = ws.benchRuns.get(p.runId);
+ const baseline = ws.benchRuns.get(p.baselineId);
+ if (!run || !baseline) throw new Error('Unknown benchmark run');
+ const metrics = Object.keys(run.metrics).map((name) => {
+ const current = BigInt(run.metrics[name] ?? '0');
+ const base = BigInt(baseline.metrics[name] ?? '0');
+ return {
+ name,
+ current: current.toString(),
+ baseline: base.toString(),
+ deltaPct: pctDelta(current, base),
+ regression: LOWER_IS_BETTER.has(name) && current > base
+ };
+ });
+ return { metrics };
+ },
// ── step ──
'step.begin': async (p) => {
@@ -734,7 +763,7 @@ export const HANDLERS: Record = {
// ── test ──
'test.run': async () => ({ runId: `run-${++subSeq}` }),
- 'test.subscribe': async (p, conn) => {
+ 'test.subscribe': async (_p, conn) => {
const id = `test-${++subSeq}`;
conn.subIds.add(id);
setTimeout(() => {
@@ -817,3 +846,86 @@ async function takeSnapshotFrom(ws: Workspace, name: string, snap: any, parentId
ws.snapshots.push(entry);
return { id, name: entry.name, createdAt: entry.createdAt, parentId };
}
+
+// ── benchmark ─────────────────────────────────────────────────────────────
+// Metrics where a larger value is worse; compare() flags these as regressions.
+const LOWER_IS_BETTER = new Set([
+ 'Total fees',
+ 'Compute gas',
+ 'Compute fees',
+ 'Action fees',
+ 'Storage fees',
+ 'Message cells',
+ 'Message bits'
+]);
+
+function txDepth(tx: any): number {
+ let depth = 1;
+ for (let cur = tx.parent; cur; cur = cur.parent) depth++;
+ return depth;
+}
+
+/**
+ * Aggregate real per-phase fees/gas over the transactions executed since the
+ * previous capture. Interval-based on purpose: txHistory is append-only (even
+ * across snapshot restores), so cumulative totals would flag every
+ * lower-is-better metric as a regression whenever any transaction ran between
+ * two captures. Per-interval metrics make compare() measure per-scenario cost.
+ */
+function captureBench(ws: Workspace, name: string): BenchRun {
+ let totalFees = 0n;
+ let computeGas = 0n;
+ let computeFees = 0n;
+ let actionFees = 0n;
+ let storageFees = 0n;
+ let messageCells = 0n;
+ let messageBits = 0n;
+ let maxDepth = 0;
+
+ const txs = ws.txHistory.slice(ws.benchWatermark);
+ for (const tx of txs) {
+ const d = tx.description ?? {};
+ totalFees += BigInt(tx.totalFees?.coins ?? 0n);
+ storageFees += BigInt(d.storagePhase?.storageFeesCollected ?? 0n);
+ const cp = d.computePhase;
+ if (cp && cp.type !== 'skipped') {
+ computeGas += BigInt(cp.gasUsed ?? 0n);
+ computeFees += BigInt(cp.gasFees ?? 0n);
+ }
+ const ap = d.actionPhase;
+ if (ap) {
+ actionFees += BigInt(ap.totalActionFees ?? 0n);
+ messageCells += BigInt(ap.totalMessageSize?.cells ?? 0n);
+ messageBits += BigInt(ap.totalMessageSize?.bits ?? 0n);
+ }
+ maxDepth = Math.max(maxDepth, txDepth(tx));
+ }
+
+ const id = `bench-${++ws.benchCounter}`;
+ const run: BenchRun = {
+ id,
+ name: name || id,
+ path: `mem://${id}`,
+ createdAt: Date.now(),
+ metrics: {
+ 'Transactions': String(txs.length),
+ 'Total fees': totalFees.toString(),
+ 'Compute gas': computeGas.toString(),
+ 'Compute fees': computeFees.toString(),
+ 'Action fees': actionFees.toString(),
+ 'Storage fees': storageFees.toString(),
+ 'Message cells': messageCells.toString(),
+ 'Message bits': messageBits.toString(),
+ 'Max tree depth': String(maxDepth)
+ }
+ };
+ ws.benchRuns.set(id, run);
+ ws.benchWatermark = ws.txHistory.length;
+ return run;
+}
+
+/** (current - baseline) / baseline * 100, bigint-safe, divide-by-zero guarded. */
+function pctDelta(current: bigint, baseline: bigint): number {
+ if (baseline === 0n) return current === 0n ? 0 : 100;
+ return Number(((current - baseline) * 10000n) / baseline) / 100;
+}
diff --git a/packages/bridge/state.ts b/packages/bridge/state.ts
index 85fa382..7ff2203 100644
--- a/packages/bridge/state.ts
+++ b/packages/bridge/state.ts
@@ -32,6 +32,16 @@ export interface Subscription {
send: (method: string, params: unknown) => void;
}
+/** A captured benchmark run: real aggregate metrics over executed transactions. */
+export interface BenchRun {
+ id: string;
+ name: string;
+ path: string;
+ createdAt: number;
+ /** Metric label -> decimal string (nanoton/gas can exceed 2^53, so no numbers). */
+ metrics: Record;
+}
+
export interface Workspace {
id: string;
name: string;
@@ -44,6 +54,7 @@ export interface Workspace {
vmLogsByTx: Map;
txByTx: Map;
snapshots: SnapshotEntry[];
+ benchRuns: Map;
subscriptions: Map;
stepIters: Map>;
// Instruction-level coverage snapshots keyed by a run id (contract ref), for coverage.merge.
@@ -53,6 +64,11 @@ export interface Workspace {
randomSeed?: Buffer;
paused: boolean;
snapCounter: number;
+ benchCounter: number;
+ // txHistory index where the last benchmark capture ended; the next capture
+ // aggregates only transactions appended after it (txHistory is append-only,
+ // snapshot restores never rewind it).
+ benchWatermark: number;
}
const workspaces = new Map();
@@ -89,13 +105,16 @@ async function build(id: string, name: string, path: string): Promise
vmLogsByTx: new Map(),
txByTx: new Map(),
snapshots: [],
+ benchRuns: new Map(),
subscriptions: new Map(),
stepIters: new Map(),
coverageRuns: new Map(),
libs: new Map(),
autoDeployLibs: false,
paused: false,
- snapCounter: 0
+ snapCounter: 0,
+ benchCounter: 0,
+ benchWatermark: 0
};
}
diff --git a/packages/mock-bridge/server.mjs b/packages/mock-bridge/server.mjs
index da3bea2..847db03 100644
--- a/packages/mock-bridge/server.mjs
+++ b/packages/mock-bridge/server.mjs
@@ -38,6 +38,14 @@ const defaultContractState = (address) => ({ address: address ?? '0:jetton-maste
const accountStates = {}; // address -> ContractState (edited via the A8 state editor)
const vmSteps = { steps: [{ index: 0, opcode: 'PUSHINT 0', gas: '5', stackBefore: [], stackAfter: ['0'], stackDiff: '+0', exitCode: 0, exitCodeName: 'ok' }] };
+let benchSeq = 2;
+const benchRuns = [
+ { id: 'bench-1', name: 'baseline', path: 'mem://bench-1', createdAt: Date.now() - 60000, metrics: { 'Transactions': '2', 'Total fees': '4200000', 'Compute gas': '2400', 'Compute fees': '2400000', 'Action fees': '1000000', 'Storage fees': '0', 'Message cells': '4', 'Message bits': '2048', 'Max tree depth': '2' } },
+ { id: 'bench-2', name: 'current', path: 'mem://bench-2', createdAt: Date.now() - 30000, metrics: { 'Transactions': '5', 'Total fees': '9800000', 'Compute gas': '5200', 'Compute fees': '5200000', 'Action fees': '2200000', 'Storage fees': '0', 'Message cells': '9', 'Message bits': '4608', 'Max tree depth': '3' } }
+];
+const benchLowerIsBetter = new Set(['Total fees', 'Compute gas', 'Compute fees', 'Action fees', 'Storage fees', 'Message cells', 'Message bits']);
+const benchPctDelta = (current, baseline) => (baseline === 0n ? (current === 0n ? 0 : 100) : Number(((current - baseline) * 10000n) / baseline) / 100);
+
wss.on('connection', (ws) => {
ws.on('message', (raw) => {
const req = JSON.parse(String(raw));
@@ -70,8 +78,6 @@ wss.on('connection', (ws) => {
'coverage.enable': { ok: true },
'coverage.get': { source: 'func', sourcePath: 'contracts/jetton.fc', lines: [{ line: 1, hits: 1, opcodes: 3 }] },
'coverage.merge': { source: 'func', sourcePath: 'contracts/jetton.fc', lines: [{ line: 1, hits: 2, opcodes: 3 }] },
- 'bench.listSnapshots': { snapshots: [{ id: 'bench-1', path: '.snapshot/bench-1.json', createdAt: Date.now() }] },
- 'bench.compare': { metrics: [{ name: 'gas', current: '1000', baseline: '900', deltaPct: 11.1, regression: true }] },
'step.begin': { iterId: 'iter-1', done: false },
'step.next': { node: jettonTransfer.tree, done: true },
'step.state': { address: req.params?.address ?? '0:jetton-master', balance: '1000000000', lastTransactionLt: '1003' },
@@ -134,6 +140,30 @@ wss.on('connection', (ws) => {
accounts.push(acc);
return ok(acc);
}
+ if (req.method === 'bench.capture') {
+ const prev = benchRuns[benchRuns.length - 1];
+ const id = `bench-${++benchSeq}`;
+ const metrics = {};
+ for (const [k, v] of Object.entries(prev.metrics)) {
+ metrics[k] = (BigInt(v) + BigInt(k === 'Max tree depth' ? 0 : Math.ceil(Number(v) * 0.15) + 1)).toString();
+ }
+ const run = { id, name: req.params?.name || id, path: `mem://${id}`, createdAt: Date.now(), metrics };
+ benchRuns.push(run);
+ return ok({ id: run.id, path: run.path, createdAt: run.createdAt });
+ }
+ if (req.method === 'bench.listSnapshots') {
+ return ok({ snapshots: [...benchRuns].reverse().map((r) => ({ id: r.id, name: r.name, path: r.path, createdAt: r.createdAt })) });
+ }
+ if (req.method === 'bench.compare') {
+ const run = benchRuns.find((r) => r.id === req.params?.runId) ?? benchRuns[benchRuns.length - 1];
+ const base = benchRuns.find((r) => r.id === req.params?.baselineId) ?? benchRuns[0];
+ const metrics = Object.keys(run.metrics).map((name) => {
+ const current = BigInt(run.metrics[name] ?? '0');
+ const baseline = BigInt(base.metrics[name] ?? '0');
+ return { name, current: current.toString(), baseline: baseline.toString(), deltaPct: benchPctDelta(current, baseline), regression: benchLowerIsBetter.has(name) && current > baseline };
+ });
+ return ok({ metrics });
+ }
if (req.method === 'logs.subscribe') {
ok({ subscriptionId: 'logs-1' });
setTimeout(() => ws.send(JSON.stringify({ method: 'logs.notify', params: { subscriptionId: 'logs-1', entry: { ts: Date.now(), kind: 'debug', text: 'mock log entry' } } })), 25);
diff --git a/packages/mock-bridge/src/server.ts b/packages/mock-bridge/src/server.ts
index 36f7a62..5b96ff2 100644
--- a/packages/mock-bridge/src/server.ts
+++ b/packages/mock-bridge/src/server.ts
@@ -35,6 +35,16 @@ const workspaceContracts: Record = { 'demo-workspace':
const defaultContractState = (address: string) => ({ address: address || '0:jetton-master', balance: '1000000000', stateInit: { code: 'te6ccgEBAQEAAgAAAA==', data: 'te6ccgEBAQEAAgAAAA==' } as { code: string; data: string } | undefined, lastTransactionLt: '1003', status: 'active', codeHash: 'a3f1c9e84b27d6510f8b2a4e9c7d3b1068f5e2a0c4d9b7e6f1a8c3d52b09e7f4a' });
const accountStates: Record> = {};
+type MockBenchRun = { id: string; name: string; path: string; createdAt: number; metrics: Record };
+let benchSeq = 2;
+const benchRuns: MockBenchRun[] = [
+ { id: 'bench-1', name: 'baseline', path: 'mem://bench-1', createdAt: Date.now() - 60000, metrics: { 'Transactions': '2', 'Total fees': '4200000', 'Compute gas': '2400', 'Compute fees': '2400000', 'Action fees': '1000000', 'Storage fees': '0', 'Message cells': '4', 'Message bits': '2048', 'Max tree depth': '2' } },
+ { id: 'bench-2', name: 'current', path: 'mem://bench-2', createdAt: Date.now() - 30000, metrics: { 'Transactions': '5', 'Total fees': '9800000', 'Compute gas': '5200', 'Compute fees': '5200000', 'Action fees': '2200000', 'Storage fees': '0', 'Message cells': '9', 'Message bits': '4608', 'Max tree depth': '3' } }
+];
+const benchLowerIsBetter = new Set(['Total fees', 'Compute gas', 'Compute fees', 'Action fees', 'Storage fees', 'Message cells', 'Message bits']);
+const benchPctDelta = (current: bigint, baseline: bigint): number =>
+ baseline === 0n ? (current === 0n ? 0 : 100) : Number(((current - baseline) * 10000n) / baseline) / 100;
+
wss.on('connection', (ws) => {
ws.on('message', (raw) => {
const req = JSON.parse(String(raw));
@@ -150,8 +160,30 @@ wss.on('connection', (ws) => {
case 'logs.setVerbosityForAddress': ok({ ok: true }); break;
case 'coverage.get':
case 'coverage.merge': ok({ source: 'func', sourcePath: 'contracts/jetton.fc', lines: [{ line: 1, hits: 1, opcodes: 3 }] }); break;
- case 'bench.listSnapshots': ok({ snapshots: [{ id: 'bench-1', path: '.snapshot/bench-1.json', createdAt: Date.now() }] }); break;
- case 'bench.compare': ok({ metrics: [{ name: 'gas', current: '1000', baseline: '900', deltaPct: 11.1, regression: true }] }); break;
+ case 'bench.capture': {
+ const prev = benchRuns[benchRuns.length - 1];
+ const id = `bench-${++benchSeq}`;
+ const metrics: Record = {};
+ for (const [k, v] of Object.entries(prev.metrics)) {
+ metrics[k] = (BigInt(v) + BigInt(k === 'Max tree depth' ? 0 : Math.ceil(Number(v) * 0.15) + 1)).toString();
+ }
+ const run: MockBenchRun = { id, name: req.params?.name || id, path: `mem://${id}`, createdAt: Date.now(), metrics };
+ benchRuns.push(run);
+ ok({ id: run.id, path: run.path, createdAt: run.createdAt });
+ break;
+ }
+ case 'bench.listSnapshots': ok({ snapshots: [...benchRuns].reverse().map((r) => ({ id: r.id, name: r.name, path: r.path, createdAt: r.createdAt })) }); break;
+ case 'bench.compare': {
+ const run = benchRuns.find((r) => r.id === req.params?.runId) ?? benchRuns[benchRuns.length - 1];
+ const base = benchRuns.find((r) => r.id === req.params?.baselineId) ?? benchRuns[0];
+ const metrics = Object.keys(run.metrics).map((name) => {
+ const current = BigInt(run.metrics[name] ?? '0');
+ const baseline = BigInt(base.metrics[name] ?? '0');
+ return { name, current: current.toString(), baseline: baseline.toString(), deltaPct: benchPctDelta(current, baseline), regression: benchLowerIsBetter.has(name) && current > baseline };
+ });
+ ok({ metrics });
+ break;
+ }
case 'step.begin': ok({ iterId: 'iter-1', done: false }); break;
case 'step.next': ok({ node: jettonTransfer.tree, done: true }); break;
case 'step.state': ok({ address: req.params.address, balance: '1000000000', lastTransactionLt: '1003' }); break;