Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions packages/typewriter-evm/src/harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ struct SimulateOk {
#[serde(flatten)]
outcome: TxOutcome,
gas_limit: u64,
/// Writes to the called contract, sorted by slot.
slot_writes: Vec<SlotWrite>,
}

#[derive(Serialize)]
Expand Down Expand Up @@ -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<AccessList> {
Expand Down
7 changes: 7 additions & 0 deletions packages/typewriter-evm/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/typewriter-evm/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
5 changes: 5 additions & 0 deletions packages/typewriter/bunfig.toml
Original file line number Diff line number Diff line change
@@ -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"
1 change: 1 addition & 0 deletions packages/typewriter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
8 changes: 8 additions & 0 deletions packages/typewriter/simulation-test/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions packages/typewriter/src/debug.ts
Original file line number Diff line number Diff line change
@@ -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());
}
101 changes: 96 additions & 5 deletions packages/typewriter/src/typewriter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
updateMutationInclusion,
} from "./db-query";
import type { TypewriterMutationSchema } from "./db-schema";
import { assert } from "./debug";
import {
decodeAuthorizationCalldata,
decodeMutationCalldata,
Expand Down Expand Up @@ -457,15 +458,22 @@ export function enqueueMutation(params: {
mutation: EnqueuedMutation;
}): Effect.Effect<ExecuteResult, EvmError> {
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;
});
}

Expand Down Expand Up @@ -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<number>): 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,
Expand Down Expand Up @@ -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<number>();
const journalMutations = new Map<number, RuntimeMutation>();
const evm = globalThis.DEBUG
? trackJournals(runtimeState.evm, journalIds)
: runtimeState.evm;

const watch = yield* makeWatch({
fromBlock: startBlock.hash,
Expand Down Expand Up @@ -830,6 +875,10 @@ export function createTypewriterEffect<

nextExecutionIndex++;

if (globalThis.DEBUG) {
journalMutations.set(acceptedMutation.journalId, acceptedMutation);
}

invalidateStorageCache(
executeResult.slot_writes.map((write) => write.slot),
);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
}),
);
}
}),
),
);
Expand Down
Loading