diff --git a/packages/typewriter-evm/src/harness.rs b/packages/typewriter-evm/src/harness.rs index 52c1af0..2e4f813 100644 --- a/packages/typewriter-evm/src/harness.rs +++ b/packages/typewriter-evm/src/harness.rs @@ -164,6 +164,8 @@ struct SimulateOk { #[serde(flatten)] outcome: TxOutcome, gas_limit: u64, + /// Writes to the called contract, sorted by slot. + slot_writes: Vec, } #[derive(Serialize)] @@ -549,14 +551,21 @@ impl EvmHarness { } }; - let (result, _) = self.run_tx(tx, access_list.clone())?; + let (result, state) = self.run_tx(tx, access_list.clone())?; let outcome = tx_outcome(&result, access_list); - let gas_limit = if outcome.success() { - result.tx_gas_used() + self.evm.0.inspector.gas_reserve + let (gas_limit, slot_writes) = if outcome.success() { + ( + result.tx_gas_used() + self.evm.0.inspector.gas_reserve, + collect_slot_writes(&state, tx.to), + ) } else { - result.tx_gas_used() + (result.tx_gas_used(), Vec::new()) }; - Ok(SimulateOk { outcome, gas_limit }) + Ok(SimulateOk { + outcome, + gas_limit, + slot_writes, + }) } fn merge_journal_access_lists(&self, journal_ids: &[u64]) -> Option { diff --git a/packages/typewriter-evm/src/index.test.ts b/packages/typewriter-evm/src/index.test.ts index 66dfb9a..3da3f91 100644 --- a/packages/typewriter-evm/src/index.test.ts +++ b/packages/typewriter-evm/src/index.test.ts @@ -334,6 +334,13 @@ test("simulate only rewinds the provided journal ids", async () => { const sim = await Effect.runPromise(Effect.scoped(program)); expect(sim.status).toBe("success"); + expect(sim.slot_writes).toEqual([ + { + slot: "0x0000000000000000000000000000000000000000000000000000000000000000", + value: + "0x0000000000000000000000000000000000000000000000000000000000000002", + }, + ]); }); test("revertJournals works on a journal", async () => { diff --git a/packages/typewriter-evm/src/types.ts b/packages/typewriter-evm/src/types.ts index d34c746..a48467c 100644 --- a/packages/typewriter-evm/src/types.ts +++ b/packages/typewriter-evm/src/types.ts @@ -62,6 +62,8 @@ export type ExecuteResult = TxResult & { // assumes no nested call halts and is caught (see harness.rs). export type SimulateResult = TxResult & { gas_limit: number; + // Writes to the called contract. + slot_writes: { slot: Hex.Hex; value: Hex.Hex }[]; } & ({ status: "success" } | Revert | Halt); export type ReadStorageParams = { diff --git a/packages/typewriter/bunfig.toml b/packages/typewriter/bunfig.toml index 6360a2f..0217cf8 100644 --- a/packages/typewriter/bunfig.toml +++ b/packages/typewriter/bunfig.toml @@ -1,2 +1,7 @@ [test] timeout = 30000 + +# Tests and the simulation run with the runtime's assertions. `bun run build` +# defines it false. See `src/debug.ts`. +[define] +"globalThis.DEBUG" = "true" diff --git a/packages/typewriter/package.json b/packages/typewriter/package.json index e9a6b90..6ee396c 100644 --- a/packages/typewriter/package.json +++ b/packages/typewriter/package.json @@ -8,6 +8,7 @@ "type": "module", "private": true, "scripts": { + "build": "bun build ./src/index.ts ./src/client.ts --outdir dist --target bun --packages external --define globalThis.DEBUG=false --minify-syntax --sourcemap=linked", "test": "bun test --timeout 30000", "typecheck": "tsc --noEmit" }, diff --git a/packages/typewriter/simulation-test/index.ts b/packages/typewriter/simulation-test/index.ts index 31f8775..649cc2e 100644 --- a/packages/typewriter/simulation-test/index.ts +++ b/packages/typewriter/simulation-test/index.ts @@ -103,6 +103,14 @@ if (DATABASE_URL === undefined || DATABASE_URL === "") { process.exit(1); } +// The simulation checks the runtime's assertions, which `bunfig.toml` turns on. +if (globalThis.DEBUG !== true) { + console.error( + "DEBUG is not defined: run the simulation from packages/typewriter so that its bunfig.toml applies", + ); + process.exit(1); +} + /** The configuration of a run. Each value is drawn from `SEED` and its path. */ export const SIM: SIM = { seed: SEED, diff --git a/packages/typewriter/src/debug.ts b/packages/typewriter/src/debug.ts new file mode 100644 index 0000000..23225ea --- /dev/null +++ b/packages/typewriter/src/debug.ts @@ -0,0 +1,26 @@ +declare global { + /** + * Turns on the runtime's internal assertions. `bunfig.toml` defines it true + * for tests and the simulation, and `bun run build` defines it false so the + * bundler drops every `if (globalThis.DEBUG)` block. Code that imports the + * source without either define leaves it undefined, so the checks stay off. + */ + var DEBUG: boolean | undefined; +} + +/** An internal invariant does not hold, so the runtime has a bug. */ +export class AssertionError extends Error { + override name = "AssertionError"; +} + +/** + * Throws an {@link AssertionError} when `condition` is false. Call it only + * inside `if (globalThis.DEBUG)`, so that production builds drop the check + * and the work to compute its arguments. + */ +export function assert( + condition: unknown, + message: () => string, +): asserts condition { + if (!condition) throw new AssertionError(message()); +} diff --git a/packages/typewriter/src/typewriter.ts b/packages/typewriter/src/typewriter.ts index 8aed1b3..f06dd78 100644 --- a/packages/typewriter/src/typewriter.ts +++ b/packages/typewriter/src/typewriter.ts @@ -50,6 +50,7 @@ import { updateMutationInclusion, } from "./db-query"; import type { TypewriterMutationSchema } from "./db-schema"; +import { assert } from "./debug"; import { decodeAuthorizationCalldata, decodeMutationCalldata, @@ -457,15 +458,22 @@ export function enqueueMutation(params: { mutation: EnqueuedMutation; }): Effect.Effect { return Effect.gen(function* () { - // TODO(kyle) prune the enqueue's journal. Nothing reverts or replays it, - // so it stays in the EVM's journal map for the life of the process. const enqueueCalldata = encodeEnqueueCalldata(params.mutation); - return yield* params.evm.execute({ + const executeResult = yield* params.evm.execute({ from: "0x0000000000000000000000000000000000000000", to: params.app.address, data: enqueueCalldata, }); + + // The enqueue is already onchain, so a settlement never rewinds it. + if (executeResult.status === "success") { + yield* params.evm.pruneJournals({ + journal_ids: [executeResult.journal_id], + }); + } + + return executeResult; }); } @@ -528,6 +536,35 @@ function revertMessage(prefix: string, errorName: string | undefined) { return errorName === undefined ? prefix : `${prefix}: ${errorName}`; } +/** + * `evm`, but it adds the journal of each successful `execute` to `journalIds` + * and removes the journals that `pruneJournals` and `revertJournals` drop, so + * `journalIds` holds the journals revm holds. + */ +function trackJournals(evm: EVM, journalIds: Set): EVM { + const drop = ({ journal_ids }: { journal_ids: number[] }) => + Effect.sync(() => { + for (const journalId of journal_ids) journalIds.delete(journalId); + }); + return { + ...evm, + execute: (params) => + evm.execute(params).pipe( + Effect.tap((result) => + Effect.sync(() => { + if (result.status === "success") { + journalIds.add(result.journal_id); + } + }), + ), + ), + pruneJournals: (params) => + evm.pruneJournals(params).pipe(Effect.tap(() => drop(params))), + revertJournals: (params) => + evm.revertJournals(params).pipe(Effect.tap(() => drop(params))), + }; +} + function createRuntimeState( app: TypewriterApp, db: Db, @@ -692,8 +729,16 @@ export function createTypewriterEffect< nextExecutionIndex, ); - const { evm, accounts, state, preimages, invalidateStorageCache } = - yield* createRuntimeState(app, db, code); + const runtimeState = yield* createRuntimeState(app, db, code); + const { accounts, state, preimages, invalidateStorageCache } = runtimeState; + + // Debug builds check that revm holds a journal for each accepted mutation + // that is not finalized, and for nothing else. + const journalIds = new Set(); + const journalMutations = new Map(); + const evm = globalThis.DEBUG + ? trackJournals(runtimeState.evm, journalIds) + : runtimeState.evm; const watch = yield* makeWatch({ fromBlock: startBlock.hash, @@ -830,6 +875,10 @@ export function createTypewriterEffect< nextExecutionIndex++; + if (globalThis.DEBUG) { + journalMutations.set(acceptedMutation.journalId, acceptedMutation); + } + invalidateStorageCache( executeResult.slot_writes.map((write) => write.slot), ); @@ -1057,6 +1106,23 @@ export function createTypewriterEffect< }); } + if (globalThis.DEBUG) { + // The settlement, run from the state before its mutations, must + // leave the state that accepting them left, since that is the + // state `execute` callers and `state` readers were shown. + const speculative = yield* evm.readStorage({ + address: app.address, + slots: simulateResult.slot_writes.map(({ slot }) => slot), + }); + for (const { slot, value } of simulateResult.slot_writes) { + assert( + speculative[slot] === value, + () => + `settlement writes ${value} to slot ${slot}, but the accepted mutations left ${speculative[slot]}`, + ); + } + } + yield* Effect.logDebug("simulated transaction submission").pipe( Effect.annotateLogs({ nonce, @@ -1311,6 +1377,31 @@ export function createTypewriterEffect< unfinalizedBlocks = unfinalizedBlocks.filter( (block) => block.status !== "finalized", ); + + if (globalThis.DEBUG) { + // Under the lock, so no mutation is between revm and the map. + yield* withSpeculativeStateLock( + Effect.sync(() => { + for (const journalId of journalIds) { + const mutation = journalMutations.get(journalId); + assert( + mutation !== undefined && mutation.status !== "finalized", + () => + `revm holds journal ${journalId}, which belongs to ${mutation === undefined ? "no mutation" : `a finalized mutation (${mutation.name})`}`, + ); + } + for (const [journalId, mutation] of journalMutations) { + if (journalIds.has(journalId)) continue; + assert( + mutation.status === "finalized", + () => + `revm dropped journal ${journalId} of a mutation that is ${mutation.status} (${mutation.name})`, + ); + journalMutations.delete(journalId); + } + }), + ); + } }), ), );