diff --git a/packages/typewriter/simulation-test/clock.ts b/packages/typewriter/simulation-test/clock.ts index e3e1386..905e759 100644 --- a/packages/typewriter/simulation-test/clock.ts +++ b/packages/typewriter/simulation-test/clock.ts @@ -51,7 +51,7 @@ export function makeSimClock( }); }); - const nanos = () => BigInt(now) * 1_000_000n; + const nanos = () => BigInt(Math.round(now * 1_000_000)); return { currentTimeMillisUnsafe: () => now, diff --git a/packages/typewriter/simulation-test/index.ts b/packages/typewriter/simulation-test/index.ts index 31f8775..e87174e 100644 --- a/packages/typewriter/simulation-test/index.ts +++ b/packages/typewriter/simulation-test/index.ts @@ -9,7 +9,7 @@ import { Exit, FiberSet, Layer, - type LogLevel, + Logger, Option, Random, Scope, @@ -20,7 +20,7 @@ import { KeyType } from "../src/client"; import type { TypewriterManifest } from "../src/config"; import { Database, makeDb } from "../src/db"; import { encodeEnqueueCalldata } from "../src/encoding"; -import { loggerLayer } from "../src/logger"; +import { logger, loggerLayer } from "../src/logger"; import { layerRpc } from "../src/rpc"; import { loadSolidityTypewriterApp } from "../src/sol-parse"; import { createTypewriterEffect, type ProgramError } from "../src/typewriter"; @@ -201,12 +201,6 @@ const CLIENT = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; */ const ENQUEUE_GAS = Hex.fromNumber(1_000_000); -/** - * Logs a lifecycle event of the run. `None` is the one level that no minimum - * level filters out, so these print at every `TYPEWRITER_LOG_LEVEL`. - */ -const logSim = Effect.logWithLevel("None" as LogLevel.Severity); - /** * A database of its own for the run. It is dropped when the run passes, and * kept for inspection when it fails or is interrupted. @@ -232,12 +226,12 @@ const makeDatabase = Effect.acquireRelease( await admin.close(); }).pipe( Effect.andThen( - logSim("dropped database").pipe( + Effect.logInfo("Dropped simulation database").pipe( Effect.annotateLogs({ database: name }), ), ), ) - : logSim("kept database").pipe( + : Effect.logInfo("Kept simulation database").pipe( Effect.annotateLogs({ database: name, seed: SIM.seed, @@ -248,17 +242,17 @@ const makeDatabase = Effect.acquireRelease( ); const program = Effect.gen(function* () { - yield* logSim("starting simulation").pipe( + yield* Effect.logInfo("Started simulation").pipe( Effect.annotateLogs({ seed: SIM.seed, config: SIM }), ); const chain = yield* makeChain(); const address = yield* deploy({ chain, deployer: SCHEDULER.address }); const chainId = Number(yield* chain.request({ method: "eth_chainId" })); - yield* logSim("deployed contract").pipe( - Effect.annotateLogs({ address, chainId }), + yield* Effect.logInfo("Deployed simulation contract").pipe( + Effect.annotateLogs({ address, chain_id: chainId }), ); const database = yield* makeDatabase; - yield* logSim("created database").pipe( + yield* Effect.logInfo("Created simulation database").pipe( Effect.annotateLogs({ database: database.name }), ); const app = yield* Effect.promise(() => @@ -474,9 +468,12 @@ const program = Effect.gen(function* () { Effect.provide( Layer.effect(Random.Random, makeRandom(SIM.seed, "runtime")), ), + // `TYPEWRITER_LOG_LEVEL` applies to the runtime only. The run's own logs + // print at every level. + Effect.provide(loggerLayer), ); - yield* logSim("running world").pipe( + yield* Effect.logInfo("Started simulation world").pipe( Effect.annotateLogs({ accounts: signers.length, steps: plan.length, @@ -511,11 +508,11 @@ const program = Effect.gen(function* () { clock.advanceTo(next); } const typewriter = yield* exit; - yield* logSim("settled mutations").pipe( + yield* Effect.logInfo("Settled simulation mutations").pipe( Effect.annotateLogs({ mutations, - virtualMs: clock.currentTimeMillisUnsafe() - startMs, - wallMs: Math.round(performance.now() - wallStart), + virtual_ms: Math.round(clock.currentTimeMillisUnsafe() - startMs), + wall_ms: Math.round(performance.now() - wallStart), }), ); @@ -533,21 +530,22 @@ const program = Effect.gen(function* () { ordering: SIM.typewriter.ordering, }); - yield* logSim("validated run").pipe( + yield* Effect.logInfo("Validated simulation run").pipe( Effect.annotateLogs({ mutations, blocks: blockEvents.length, batches: batchEvents.length, - wallMs: Math.round(performance.now() - wallStart), + wall_ms: Math.round(performance.now() - wallStart), }), ); }); BunRuntime.runMain( program.pipe( + Effect.tapCause((cause) => Effect.logError("Failed simulation", cause)), Effect.scoped, - Effect.annotateLogs({ category: "sim" }), - Effect.provide(loggerLayer), + Effect.provide(Logger.layer([logger])), Effect.provide(BunServices.layer), ), + { disableErrorReporting: true }, ); diff --git a/packages/typewriter/src/config.ts b/packages/typewriter/src/config.ts index c2417f3..f2eebe3 100644 --- a/packages/typewriter/src/config.ts +++ b/packages/typewriter/src/config.ts @@ -1,5 +1,6 @@ import type { AbiParameterToPrimitiveType } from "abitype"; import type { PgTable } from "drizzle-orm/pg-core"; +import { Data } from "effect"; import type { Address } from "ox"; import type { StorageLayout } from "storage-layout"; import type { Abi, AbiParameter, PrivateKeyAccount } from "viem"; @@ -15,6 +16,12 @@ import { FIRST_BUILTIN_MUTATION_ID, } from "./typewriter-constants"; +/** An invalid typewriter config or Solidity entrypoint. */ +export class ConfigError extends Data.TaggedError("ConfigError")<{ + message: string; + cause?: unknown; +}> {} + export type StorageConfig = StorageLayout; export type MutationConfig = { params: readonly AbiParameter[]; @@ -129,13 +136,15 @@ function assertSafeNonNegativeInteger( ): void { if (value === undefined) return; if (!Number.isSafeInteger(value) || value < 0) { - throw new Error(`${name} must be a safe non-negative integer`); + throw new ConfigError({ + message: `${name} must be a safe non-negative integer`, + }); } } export function validateConfig(config: TypewriterConfig): void { if (Array.isArray(config.rpcUrl) && config.rpcUrl.length === 0) { - throw new Error("At least one RPC URL is required"); + throw new ConfigError({ message: "At least one RPC URL is required" }); } assertSafeNonNegativeInteger( @@ -152,15 +161,15 @@ export function validateConfig(config: TypewriterConfig): void { config.sequencing !== undefined && "batchOrder" in config.sequencing; if (config.sequencing?.order === "batch" && hasBatchOrder === false) { - throw new Error( - "config.sequencing.batchOrder is required for batch ordering", - ); + throw new ConfigError({ + message: "config.sequencing.batchOrder is required for batch ordering", + }); } if (config.sequencing?.order === "fifo" && hasBatchOrder) { - throw new Error( - "config.sequencing.batchOrder is only valid for batch ordering", - ); + throw new ConfigError({ + message: "config.sequencing.batchOrder is only valid for batch ordering", + }); } } @@ -174,18 +183,22 @@ function validateMutationDefinitions(mutations: { mutation.id < 0 || mutation.id > 255 ) { - throw new Error(`mutation ID must be a uint8: ${mutation.id}`); + throw new ConfigError({ + message: `Mutation ${name} has ID ${mutation.id}; mutation IDs must be uint8s`, + }); } if (mutationIDs.has(mutation.id)) { - throw new Error(`duplicate mutation ID: ${mutation.id}`); + throw new ConfigError({ + message: `Mutation ${name} reuses mutation ID ${mutation.id}`, + }); } if ( !Object.hasOwn(BUILTIN_MUTATIONS, name) && mutation.id >= FIRST_BUILTIN_MUTATION_ID ) { - throw new Error( - `app mutation ID must be below ${FIRST_BUILTIN_MUTATION_ID}: ${name}=${mutation.id}`, - ); + throw new ConfigError({ + message: `Mutation ${name} has reserved mutation ID ${mutation.id}; app mutation IDs must be below ${FIRST_BUILTIN_MUTATION_ID}`, + }); } mutationIDs.add(mutation.id); } @@ -193,9 +206,9 @@ function validateMutationDefinitions(mutations: { for (const [name, builtin] of Object.entries(BUILTIN_MUTATIONS)) { const mutation = mutations[name]; if (mutation === undefined || mutation.id !== builtin.id) { - throw new Error( - `missing built-in mutation ${name} with ID ${builtin.id}`, - ); + throw new ConfigError({ + message: `Could not find built-in mutation ${name} with ID ${builtin.id}`, + }); } } } @@ -217,9 +230,9 @@ export function createTypewriterApp(params: { const schemaName = `typewriter_${deploymentKey(config.chainId, config.address)}`; if (schemaName.length > MAX_IDENTIFIER_LENGTH) { - throw new Error( - `config.chainId is too long for the Postgres schema name: ${schemaName}`, - ); + throw new ConfigError({ + message: `config.chainId is too long for the Postgres schema name: ${schemaName}`, + }); } const confirmations = { finalizedBlockDepth: diff --git a/packages/typewriter/src/db.ts b/packages/typewriter/src/db.ts index e2a86f6..c631f52 100644 --- a/packages/typewriter/src/db.ts +++ b/packages/typewriter/src/db.ts @@ -62,7 +62,12 @@ export class DbError extends Data.TaggedError("DbError")<{ cause: unknown; /** Set when the work is known to have not applied, so a retry is safe. */ notApplied?: boolean; -}> {} +}> { + override get message() { + const sql = this.sql.replaceAll(/\s+/g, " ").trim(); + return `Failed database query: ${sql.length > 120 ? `${sql.slice(0, 120)}...` : sql}`; + } +} const dialect = new PgDialect(); diff --git a/packages/typewriter/src/index.ts b/packages/typewriter/src/index.ts index c8aede5..c59c008 100644 --- a/packages/typewriter/src/index.ts +++ b/packages/typewriter/src/index.ts @@ -1,4 +1,4 @@ -import { Effect, Exit, Layer, Scope } from "effect"; +import { Cause, Effect, Exit, Layer, Scope } from "effect"; import type { MutationConfig, MutationsConfig, @@ -7,6 +7,7 @@ import type { TypewriterConfig, } from "./config"; import { layerDatabase } from "./db"; +import { loggerLayer } from "./logger"; import { layerRpc } from "./rpc"; import { makeHttpTransport } from "./rpc-transport"; import { @@ -96,6 +97,15 @@ export { type WatchError, } from "./watch"; +/** + * Logs `cause` and exits the process. A typewriter that failed to start or + * stopped can't accept mutations, so it crashes rather than serve stale state. + */ +function exit(msg: string, cause: Cause.Cause): never { + Effect.runSync(Effect.logError(msg, cause).pipe(Effect.provide(loggerLayer))); + process.exit(1); +} + type EntrypointMetadata = entrypoint extends TypewriterSolidityEntrypoint ? metadata @@ -134,17 +144,20 @@ export async function createTypewriter< sequencingConfig >; - const app = await loadSolidityTypewriterApp(entrypoint, config); + const app = await loadSolidityTypewriterApp(entrypoint, config).catch( + (error: unknown) => exit("Failed to start typewriter", Cause.fail(error)), + ); // TODO(kyle) expose a way to close the scope. Until then the typewriter // runs until the process exits. const scope = Effect.runSync(Scope.make()); try { - const typewriter = await Effect.runPromise( + const { typewriter, services } = await Effect.runPromise( Effect.gen(function* () { const [rpcUrl, ...rpcUrls] = app.rpcUrls; const services = yield* Layer.buildWithScope( - Layer.merge( + Layer.mergeAll( + loggerLayer, layerRpc([ makeHttpTransport(rpcUrl), ...rpcUrls.map(makeHttpTransport), @@ -162,24 +175,24 @@ export async function createTypewriter< Effect.provideService(Scope.Scope, scope), ); yield* typewriter.program.pipe( - // A failed program can't accept mutations. Crash the process rather - // than serve stale state. - Effect.tapError((error) => - Effect.sync(() => - queueMicrotask(() => { - throw error; - }), - ), + Effect.provide(services), + Effect.tapCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.void + : Effect.sync(() => exit("Stopped typewriter", cause)), ), Effect.forkIn(scope), ); - return typewriter; + return { typewriter, services }; }), ); const execute = (( submitted: TypewriterMutationInput>, - ) => Effect.runPromise(typewriter.execute(submitted))) as Result["execute"]; + ) => + Effect.runPromise( + typewriter.execute(submitted).pipe(Effect.provide(services)), + )) as Result["execute"]; const on = (( event: "mutation" | "batch" | "block", @@ -206,6 +219,6 @@ export async function createTypewriter< } as Result; } catch (error) { await Effect.runPromise(Scope.close(scope, Exit.void)); - throw error; + exit("Failed to start typewriter", Cause.fail(error)); } } diff --git a/packages/typewriter/src/logger.ts b/packages/typewriter/src/logger.ts index 291aad4..b3c3d09 100644 --- a/packages/typewriter/src/logger.ts +++ b/packages/typewriter/src/logger.ts @@ -1,18 +1,13 @@ -import { Layer, Logger, type LogLevel, References } from "effect"; - -const stringify: typeof JSON.stringify = (value, replacer, space) => - JSON.stringify( - value, - (key, value_) => { - const value = typeof value_ === "bigint" ? value_.toString() : value_; - return typeof replacer === "function" ? replacer(key, value) : value; - }, - space, - ); - -const consoleJson = Logger.withConsoleLog( - Logger.map(Logger.formatStructured, stringify), -); +import { + Cause, + Clock, + Effect, + Layer, + Logger, + type LogLevel, + Predicate, + References, +} from "effect"; const LOG_LEVELS = { trace: "Trace", @@ -54,27 +49,240 @@ const logFormat = readEnv( process.env.NODE_ENV === "production" ? "json" : "pretty", ); -const output = logFormat === "json" ? consoleJson : Logger.consolePretty(); +/** + * An error that typewriter raises on purpose: an Effect tagged error. Its + * message says what went wrong, so its stack, which points into typewriter, is + * noise. Any other error is a bug, and keeps its stack. + */ +function isKnownError(error: Error): boolean { + return Predicate.isString((error as { _tag?: unknown })._tag); +} + +/** An error's `cause`, with an Effect `Cause` reduced to its first error. */ +function errorCause(error: Error): unknown { + return Cause.isCause(error.cause) ? Cause.squash(error.cause) : error.cause; +} + +/** The errors a log is about, from its message and its cause. */ +function logErrors( + message: readonly unknown[], + cause: Cause.Cause, +): unknown[] { + const errors: unknown[] = message.filter((m) => m instanceof Error); + for (const reason of cause.reasons) { + if (Cause.isFailReason(reason)) errors.push(reason.error); + else if (Cause.isDieReason(reason)) errors.push(reason.defect); + else errors.push(new Error("Interrupted")); + } + return errors; +} + +// ----------------------------------------------------------------------------- +// Pretty + +const { NO_COLOR, FORCE_COLOR } = process.env; +const useColor = + process.stdout.isTTY === true && + NO_COLOR === undefined && + FORCE_COLOR !== "0"; + +const ansi = + (open: number, close: number) => + (text: string): string => + useColor ? `\x1b[${open}m${text}\x1b[${close}m` : text; +const dim = ansi(2, 22); +const red = ansi(31, 39); +const green = ansi(32, 39); +const yellow = ansi(33, 39); +const blue = ansi(34, 39); +const gray = ansi(90, 39); + +const PRETTY_LEVELS: Record = { + All: gray("TRACE"), + Trace: gray("TRACE"), + Debug: blue("DEBUG"), + Info: green("INFO "), + Warn: yellow("WARN "), + Error: red("ERROR"), + Fatal: red("FATAL"), + None: "", +}; + +const timeFormatter = new Intl.DateTimeFormat(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + fractionalSecondDigits: 3, + hour12: false, +}); + +export function formatDuration(ms: number): string { + if (ms < 1000) return `${Math.round(ms)}ms`; + const seconds = Math.floor(ms / 1000); + + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds - h * 3600) / 60); + const s = seconds - h * 3600 - m * 60; + + const hstr = h > 0 ? `${h}h ` : ""; + const mstr = m > 0 || h > 0 ? `${m < 10 && h > 0 ? "0" : ""}${m}m ` : ""; + const sstr = s > 0 || m > 0 ? `${s < 10 && m > 0 ? "0" : ""}${s}s` : ""; + + return `${hstr}${mstr}${sstr}`; +} + +function formatValue(value: unknown): string { + if (Predicate.isString(value)) return value; + if (value instanceof Error) return `${value.name}: ${value.message}`; + if (Predicate.isObject(value)) return stringify(value); + return String(value); +} /** - * Logs at `None` show as debug logs. The simulation test logs at `None`, the - * one level that no minimum level filters out, so its logs always print. + * `Name: message` for each error in the cause chain, or the stack of an + * unknown top-level error. The stack of a cause points into the library that + * threw it, so it is left out. */ -export const logger = Logger.make((options) => - output.log( - options.logLevel === "None" ? { ...options, logLevel: "Debug" } : options, - ), -); +function formatErrorLines(error: unknown): string[] { + const lines: string[] = []; + let current: unknown = error; + for (let depth = 0; current !== undefined && depth < 10; depth++) { + const prefix = depth === 0 ? "" : " cause: "; + if (!(current instanceof Error)) { + lines.push(`${prefix}${formatValue(current)}`); + break; + } + if (depth === 0 && !isKnownError(current) && current.stack) { + lines.push(current.stack); + } else { + const text = `${current.name}: ${current.message}`; + lines.push(`${prefix}${text.replaceAll("\n", "\n ")}`); + } + current = errorCause(current); + } + return lines; +} + +function formatPretty(options: Logger.Options): string { + const message = Array.isArray(options.message) + ? options.message + : [options.message]; + const annotations = options.fiber.getRef(References.CurrentLogAnnotations); + const msg = message.filter(Predicate.isString).join(" "); + + let keys = ""; + let duration = ""; + const errors = logErrors(message, options.cause); + for (const [key, value] of Object.entries(annotations)) { + if (key === "duration" && Predicate.isNumber(value)) { + duration = ` ${gray(`(${formatDuration(value)})`)}`; + } else if (key === "error") { + errors.unshift(value); + } else { + keys += ` ${key}=${formatValue(value)}`; + } + } + + const time = timeFormatter.format(options.date); + const lines = [ + `${dim(time)} ${PRETTY_LEVELS[options.logLevel]} ${msg}${dim(keys)}${duration}`, + ]; + for (const error of errors) lines.push(...formatErrorLines(error)); + return lines.join("\n"); +} + +// ----------------------------------------------------------------------------- +// JSON + +function stringify(value: unknown): string { + return JSON.stringify(value, (_, v) => + typeof v === "bigint" ? v.toString() : v, + ); +} + +/** + * `name`, `message`, the error's own fields, the serialized `cause`, and the + * stack of an unknown top-level error. + */ +function serializeError(error: unknown, depth = 0): unknown { + if (!(error instanceof Error)) return error; + const serialized: { + name: string; + message: string; + stack?: string; + cause?: unknown; + [key: string]: unknown; + } = { + name: error.name, + message: error.message, + }; + for (const [key, value] of Object.entries(error)) { + if (key === "_tag" || key === "cause" || key === "message") continue; + serialized[key] = value; + } + if (depth === 0 && !isKnownError(error) && error.stack) { + serialized.stack = error.stack; + } + const cause = errorCause(error); + if (cause !== undefined && depth < 10) { + serialized.cause = serializeError(cause, depth + 1); + } + return serialized; +} + +const JSON_RESERVED_KEYS = new Set(["level", "time", "msg", "error"]); + +function formatJson(options: Logger.Options): string { + const message = Array.isArray(options.message) + ? options.message + : [options.message]; + const annotations = options.fiber.getRef(References.CurrentLogAnnotations); + const log: { + level: string; + time: string; + msg: string; + error?: unknown; + [key: string]: unknown; + } = { + level: options.logLevel.toLowerCase(), + time: options.date.toISOString(), + msg: message.filter(Predicate.isString).join(" "), + }; + const errors = logErrors(message, options.cause); + for (const [key, value] of Object.entries(annotations)) { + if (key === "error") errors.unshift(value); + else if (!JSON_RESERVED_KEYS.has(key)) log[key] = value; + } + if (errors.length === 1) log.error = serializeError(errors[0]); + else if (errors.length > 1) log.error = errors.map((e) => serializeError(e)); + return stringify(log); +} + +// ----------------------------------------------------------------------------- +// Logger + +/** + * Prints one log per line in the format of `TYPEWRITER_LOG_FORMAT`. A string + * message is the log's message, and an error message, an `error` annotation, + * or the cause is printed as its error. + */ +export const logger = Logger.make((options) => { + console.log( + logFormat === "json" ? formatJson(options) : formatPretty(options), + ); +}); export const loggerLayer = Layer.merge( Logger.layer([logger]), Layer.succeed(References.MinimumLogLevel, logLevel), ); -export function startTimer(): number { - return performance.now(); -} +/** The `Clock`'s monotonic time in nanoseconds, which a simulation controls. */ +export const startTimer: Effect.Effect = Clock.clockWith( + (clock) => clock.monotonicTimeNanos, +); -export function durationMs(startMs: number): number { - return Math.round((performance.now() - startMs) * 100) / 100; +/** The milliseconds since `start`, to two decimal places. */ +export function durationMs(start: bigint): Effect.Effect { + return Effect.map(startTimer, (now) => Number((now - start) / 10_000n) / 100); } diff --git a/packages/typewriter/src/rpc-request.ts b/packages/typewriter/src/rpc-request.ts index 2df976c..fc1d259 100644 --- a/packages/typewriter/src/rpc-request.ts +++ b/packages/typewriter/src/rpc-request.ts @@ -20,7 +20,11 @@ export type RequestedBlock = Block< /** The provider returned `null` for the block. */ export class BlockNotFoundError extends Data.TaggedError("BlockNotFoundError")<{ block: Hash | bigint | "latest"; -}> {} +}> { + override get message() { + return `Block ${this.block} not found`; + } +} /** The call returned `0x`, for example because no contract is at `address`. */ export class NoDataReturnedError extends Data.TaggedError( @@ -28,7 +32,11 @@ export class NoDataReturnedError extends Data.TaggedError( )<{ address: Address.Address; functionName: string; -}> {} +}> { + override get message() { + return `Failed '${this.functionName}' call to ${this.address}: no data returned. Is a contract deployed at the address?`; + } +} export function requestBlock( block: BlockIdentifier, diff --git a/packages/typewriter/src/rpc-transport.ts b/packages/typewriter/src/rpc-transport.ts index 6ca3e60..9acc08c 100644 --- a/packages/typewriter/src/rpc-transport.ts +++ b/packages/typewriter/src/rpc-transport.ts @@ -29,7 +29,11 @@ export class RpcNetworkError extends Data.TaggedError("RpcNetworkError")<{ method: RpcMethod; origin: string; cause: unknown; -}> {} +}> { + override get message() { + return `Failed '${this.method}' request to ${this.origin}`; + } +} /** * The provider replied, but not with a JSON-RPC result or error for the @@ -41,7 +45,13 @@ export class RpcHttpError extends Data.TaggedError("RpcHttpError")<{ status: number; body: string; cause?: unknown; -}> {} +}> { + override get message() { + const body = + this.body.length > 200 ? `${this.body.slice(0, 200)}...` : this.body; + return `Failed '${this.method}' request to ${this.origin} with HTTP ${this.status}${body === "" ? "" : `: ${body}`}`; + } +} /** The provider replied with a JSON-RPC error object. */ export class RpcResponseError extends Data.TaggedError("RpcResponseError")<{ @@ -49,7 +59,11 @@ export class RpcResponseError extends Data.TaggedError("RpcResponseError")<{ origin: string; status: number; error: { code: number; message: unknown; data?: unknown }; -}> {} +}> { + override get message() { + return `Failed '${this.method}' request to ${this.origin}: ${String(this.error.message)} (code ${this.error.code})`; + } +} export type RpcTransportError = | RpcNetworkError diff --git a/packages/typewriter/src/rpc.ts b/packages/typewriter/src/rpc.ts index 4ea6dda..eb4de3e 100644 --- a/packages/typewriter/src/rpc.ts +++ b/packages/typewriter/src/rpc.ts @@ -11,7 +11,11 @@ import type { export class RpcTimeoutError extends Data.TaggedError("RpcTimeoutError")<{ method: RpcMethod; origin: string; -}> {} +}> { + override get message() { + return `Failed '${this.method}' request to ${this.origin}: timed out after ${Duration.format(REQUEST_TIMEOUT)}`; + } +} export type RpcError = RpcTransportError | RpcTimeoutError; @@ -66,13 +70,8 @@ function makeRpc( ), }), Effect.tapError((error) => - Effect.logDebug("rpc request failed").pipe( - Effect.annotateLogs({ - method, - origin: transport.origin, - attempt, - error, - }), + Effect.logDebug("Failed RPC request", error).pipe( + Effect.annotateLogs({ method, attempt }), ), ), ); diff --git a/packages/typewriter/src/sol-parse.test.ts b/packages/typewriter/src/sol-parse.test.ts index bdd611e..4ae9cff 100644 --- a/packages/typewriter/src/sol-parse.test.ts +++ b/packages/typewriter/src/sol-parse.test.ts @@ -243,6 +243,6 @@ test("parseSolidityMetadata errors when the entrypoint file does not exist", asy test("parseSolidityMetadata rejects nested struct mutation params", async () => { await expect(parseSolidityMetadata(NestedParam)).rejects.toThrow( - "unsupported Solidity type", + "Unsupported Solidity type", ); }); diff --git a/packages/typewriter/src/sol-parse.ts b/packages/typewriter/src/sol-parse.ts index 7f78bf4..03614a8 100644 --- a/packages/typewriter/src/sol-parse.ts +++ b/packages/typewriter/src/sol-parse.ts @@ -1,6 +1,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { $ } from "bun"; +import { Predicate } from "effect"; import type { Abi, AbiParameter } from "viem"; import type { ResolvedTypewriterMutationConfig, @@ -8,7 +9,7 @@ import type { StorageConfig, TypewriterConfig, } from "./config"; -import { createTypewriterApp, type TypewriterApp } from "./config"; +import { ConfigError, createTypewriterApp, type TypewriterApp } from "./config"; import { BUILTIN_MUTATIONS, FIRST_BUILTIN_MUTATION_ID, @@ -85,14 +86,15 @@ function isObject(value: unknown): value is AstNode { } function requireObject(value: unknown, name: string): AstNode { - if (!isObject(value)) throw new Error(`${name} must be an object`); + if (!isObject(value)) + throw new ConfigError({ message: `Expected ${name} to be an object` }); return value; } function asAstNode(value: unknown, name: string): AstNode { const node = requireObject(value, name) as AstNode; if (typeof node.nodeType !== "string") { - throw new Error(`${name} is missing nodeType`); + throw new ConfigError({ message: `Expected ${name} to have a nodeType` }); } return node; } @@ -176,9 +178,9 @@ async function findFoundryProject(entrypoint: string): Promise { const next = dirname(current); if (next === current || current === "") { - throw new Error( - `Could not find foundry.toml for Solidity entrypoint ${displayPath(entrypoint)}`, - ); + throw new ConfigError({ + message: `Could not find foundry.toml for Solidity entrypoint ${displayPath(entrypoint)}`, + }); } current = next; } @@ -199,7 +201,16 @@ async function buildFoundryProject( .cwd(project.root) .quiet(); } catch (cause) { - throw new Error(`forge build failed for ${project.root}`, { cause }); + // forge reports compiler errors on stdout and its own errors on stderr. + const output = + Predicate.hasProperty(cause, "stdout") && + Predicate.hasProperty(cause, "stderr") + ? `${cause.stdout}${cause.stderr}`.trim() + : ""; + throw new ConfigError({ + message: `Failed to build Foundry project ${project.root}${output === "" ? "" : `\n${output}`}`, + cause, + }); } return out; } @@ -211,7 +222,9 @@ async function readArtifact(path: string): Promise { function requireArtifactAbi(artifact: SolidityArtifact, path: string): Abi { if (!Array.isArray(artifact.abi)) { - throw new Error(`missing ABI in artifact ${path}`); + throw new ConfigError({ + message: `Could not find ABI in artifact ${path}`, + }); } return artifact.abi as Abi; } @@ -227,7 +240,9 @@ async function readEntrypointAst( if (artifact.ast !== undefined) return asAstNode(artifact.ast, "artifact ast"); } - throw new Error(`missing compiler AST for ${displayPath(entrypoint)}`); + throw new ConfigError({ + message: `Could not find compiler AST for ${displayPath(entrypoint)}`, + }); } async function readAllSourceAsts(out: string): Promise { @@ -290,16 +305,16 @@ function findEntrypointContract(entrypoint: string, ast: AstNode): AstNode { }); if (typewriterContracts.length === 0) { - throw new Error( - `${basename(entrypoint)} does not contain a contract inheriting Typewriter`, - ); + throw new ConfigError({ + message: `${basename(entrypoint)} does not contain a contract inheriting Typewriter`, + }); } if (typewriterContracts.length > 1) { - throw new Error( - `${basename(entrypoint)} contains multiple contracts inheriting Typewriter: ${typewriterContracts + throw new ConfigError({ + message: `${basename(entrypoint)} contains multiple contracts inheriting Typewriter: ${typewriterContracts .map((contract) => contract.name) .join(", ")}`, - ); + }); } return typewriterContracts[0]!; } @@ -324,9 +339,9 @@ function findContractNode( (node) => node.nodeType === nodeType && node.name === name, ); if (matches.length !== 1) { - throw new Error( - `${contract.name ?? "entrypoint"} must define exactly one ${name} ${nodeType}`, - ); + throw new ConfigError({ + message: `${contract.name ?? "entrypoint"} must define exactly one ${name} ${nodeType}`, + }); } return matches[0]!; } @@ -335,7 +350,9 @@ function abiTypeFromTypeName(typeName: unknown): string { const node = asAstNode(typeName, "typeName"); if (node.nodeType === "ElementaryTypeName") { if (typeof node.name !== "string") - throw new Error("elementary type missing name"); + throw new ConfigError({ + message: "Expected elementary type to have a name", + }); return node.name; } if (node.nodeType === "UserDefinedTypeName") { @@ -345,7 +362,7 @@ function abiTypeFromTypeName(typeName: unknown): string { ); const typeString = typeDescriptions.typeString; if (typeof typeString !== "string") - throw new Error("type missing typeString"); + throw new ConfigError({ message: "Expected type to have a typeString" }); if (typeString.startsWith("enum ")) return "uint8"; } if (node.nodeType === "ArrayTypeName") { @@ -356,17 +373,17 @@ function abiTypeFromTypeName(typeName: unknown): string { : ""; return `${baseType}[${length}]`; } - throw new Error( - `unsupported Solidity type at ${node.src ?? "unknown source"}`, - ); + throw new ConfigError({ + message: `Unsupported Solidity type at ${node.src ?? "unknown source"}`, + }); } function structToAbiParameters(struct: AstNode): readonly AbiParameter[] { return asAstNodes(struct.members).map((member) => { if (typeof member.name !== "string") { - throw new Error( - `struct member missing name at ${member.src ?? "unknown source"}`, - ); + throw new ConfigError({ + message: `Expected struct member at ${member.src ?? "unknown source"} to have a name`, + }); } return { name: member.name, @@ -379,9 +396,9 @@ function validateRootStorageLayout(layout: StorageLayout): StorageLayout { for (const label of ["accounts", "state"] as const) { const roots = layout.storage.filter((entry) => entry.label === label); if (roots.length !== 1) { - throw new Error( - `storage layout must contain exactly one top-level ${label} variable`, - ); + throw new ConfigError({ + message: `Storage layout must contain exactly one top-level ${label} variable`, + }); } const type = layout.types[roots[0]!.type] as StorageType | undefined; @@ -391,13 +408,17 @@ function validateRootStorageLayout(layout: StorageLayout): StorageLayout { typeof type.key !== "string" || typeof type.value !== "string") ) { - throw new Error("top-level accounts storage variable must be a mapping"); + throw new ConfigError({ + message: "Top-level accounts storage variable must be a mapping", + }); } if ( label === "state" && (type === undefined || !Array.isArray(type.members)) ) { - throw new Error("top-level state storage variable must be a struct"); + throw new ConfigError({ + message: "Top-level state storage variable must be a struct", + }); } } @@ -537,17 +558,19 @@ function parseMutations(params: { ); const enumMembers = asAstNodes(mutationEnum.members).map((member, id) => { if (typeof member.name !== "string") { - throw new Error( - `Mutation enum member missing name at ${member.src ?? "unknown source"}`, - ); + throw new ConfigError({ + message: `Expected Mutation enum member at ${member.src ?? "unknown source"} to have a name`, + }); } if (Object.hasOwn(BUILTIN_MUTATIONS, member.name)) { - throw new Error(`Mutation.${member.name} is reserved by Typewriter`); + throw new ConfigError({ + message: `Mutation.${member.name} is reserved by Typewriter`, + }); } if (id >= FIRST_BUILTIN_MUTATION_ID) { - throw new Error( - `Mutation.${member.name} has reserved mutation ID ${id}; app mutation IDs must be below ${FIRST_BUILTIN_MUTATION_ID}`, - ); + throw new ConfigError({ + message: `Mutation.${member.name} has reserved mutation ID ${id}; app mutation IDs must be below ${FIRST_BUILTIN_MUTATION_ID}`, + }); } return { enumName: member.name, id }; }); @@ -559,7 +582,9 @@ function parseMutations(params: { const body = asAstNode(dispatch.body, "dispatch body"); const firstStatement = asAstNodes(body.statements)[0]; if (firstStatement === undefined) - throw new Error("dispatch must contain mutation branches"); + throw new ConfigError({ + message: "Expected dispatch to contain mutation branches", + }); const branches = new Map( flattenDispatchBranches(firstStatement).map((branch) => [ branch.name, @@ -570,21 +595,21 @@ function parseMutations(params: { return enumMembers.map((member) => { const branch = branches.get(member.enumName); if (branch === undefined) { - throw new Error( - `dispatch missing branch for Mutation.${member.enumName}`, - ); + throw new ConfigError({ + message: `Could not find dispatch branch for Mutation.${member.enumName}`, + }); } const mutationStructIds = findDecodeStructIds(branch, "mutationData"); if (mutationStructIds.length !== 1) { - throw new Error( - `Mutation.${member.enumName} must decode mutationData exactly once`, - ); + throw new ConfigError({ + message: `Mutation.${member.enumName} must decode mutationData exactly once`, + }); } const mutationStruct = params.declarations.get(mutationStructIds[0]!); if (mutationStruct?.nodeType !== "StructDefinition") { - throw new Error( - `Mutation.${member.enumName} decode target is not a struct`, - ); + throw new ConfigError({ + message: `Mutation.${member.enumName} decode target is not a struct`, + }); } return { enumName: member.enumName, @@ -685,14 +710,15 @@ async function requireEntrypointPath( // around the Solidity import, so the type guarantee can be gone at runtime. const value = entrypoint as unknown; if (typeof value !== "string" || value.length === 0) { - throw new Error( - "Typewriter entrypoint must be a Solidity file path string; import the contract's .sol file and pass it as the first argument", - ); + throw new ConfigError({ + message: + "Typewriter entrypoint must be a Solidity file path string; import the contract's .sol file and pass it as the first argument", + }); } if (!(await fileExists(value))) { - throw new Error( - `Typewriter entrypoint file does not exist: ${displayPath(value)}`, - ); + throw new ConfigError({ + message: `Typewriter entrypoint file does not exist: ${displayPath(value)}`, + }); } return value; } @@ -718,7 +744,9 @@ async function readSolidityMetadata( const entrypointAst = await readEntrypointAst(out, entrypoint); const contract = findEntrypointContract(entrypoint, entrypointAst); if (typeof contract.name !== "string") - throw new Error("entrypoint contract missing name"); + throw new ConfigError({ + message: "Expected entrypoint contract to have a name", + }); const artifactPath = joinPath( out, @@ -728,9 +756,9 @@ async function readSolidityMetadata( const artifact = await readArtifact(artifactPath); const abi = requireArtifactAbi(artifact, artifactPath); if (artifact.storageLayout === undefined) { - throw new Error( - `missing storage layout for ${contract.name}; ensure the Foundry profile emits storageLayout`, - ); + throw new ConfigError({ + message: `Could not find storage layout for ${contract.name}; ensure the Foundry profile emits storageLayout`, + }); } const asts = await readReachableSourceAsts(out, entrypointAst); diff --git a/packages/typewriter/src/typewriter.ts b/packages/typewriter/src/typewriter.ts index 7cc2c8e..6a498f7 100644 --- a/packages/typewriter/src/typewriter.ts +++ b/packages/typewriter/src/typewriter.ts @@ -57,7 +57,7 @@ import { encodeEnqueueCalldata, encodeExecuteCalldata, } from "./encoding"; -import { durationMs, loggerLayer, startTimer } from "./logger"; +import { durationMs, startTimer } from "./logger"; import { Rpc, type RpcError } from "./rpc"; import { type BlockNotFoundError, @@ -150,25 +150,37 @@ export class UnknownMutationError extends Data.TaggedError( "UnknownMutationError", )<{ mutation: string; -}> {} +}> { + override get message() { + return `Unknown mutation '${this.mutation}'`; + } +} export class SignTransactionError extends Data.TaggedError( "SignTransactionError", )<{ cause: unknown; -}> {} +}> { + override get message() { + return "Failed to sign settlement transaction"; + } +} /** Each part of the program runs forever, so it stops only on a bug. */ export class ProgramStoppedError extends Data.TaggedError( "ProgramStoppedError", -) {} +) { + override get message() { + return "Typewriter program stopped unexpectedly"; + } +} /** A settlement transaction reverted when simulated in revm. */ export class SettlementSimulationRevertedError extends Data.TaggedError( "SettlementSimulationRevertedError", ) { override get message() { - return revertMessage("revm simulate reverted", this.errorName); + return revertMessage("Settlement transaction reverted in revm", this); } } @@ -179,7 +191,7 @@ export class SettlementSimulationHaltedError extends Data.TaggedError( reason: string; }> { override get message() { - return `revm simulate halted: ${this.reason}`; + return `Settlement transaction halted in revm with ${this.reason}`; } } @@ -188,7 +200,7 @@ export class EnqueueRevertedError extends Data.TaggedError( "EnqueueRevertedError", ) { override get message() { - return revertMessage("revm enqueue reverted", this.errorName); + return revertMessage("Enqueued mutation reverted", this); } } @@ -197,7 +209,7 @@ export class EnqueueHaltedError extends Data.TaggedError("EnqueueHaltedError")<{ reason: string; }> { override get message() { - return `revm enqueue halted: ${this.reason}`; + return `Enqueued mutation halted with ${this.reason}`; } } @@ -208,7 +220,7 @@ export class SettlementRevertedError extends Data.TaggedError( transactionHash: Hex.Hex; }> { override get message() { - return `settlement transaction reverted onchain: ${this.transactionHash}`; + return `Settlement transaction ${this.transactionHash} reverted onchain`; } } @@ -220,7 +232,11 @@ export class TypewriterStoppedError extends Data.TaggedError( "TypewriterStoppedError", )<{ cause: Cause.Cause; -}> {} +}> { + override get message() { + return "Typewriter stopped"; + } +} /** Why `execute` did not accept a mutation. */ export type ExecuteError = @@ -360,14 +376,18 @@ export class EncodeMutationError extends Data.TaggedError( )<{ mutation: ReceivedMutation | EnqueuedMutation; cause: unknown; -}> {} +}> { + override get message() { + return `Failed to encode '${this.mutation.name}' mutation`; + } +} /** A mutation reverted when executed in revm. */ export class MutationRevertedError extends Data.TaggedError( "MutationRevertedError", ) { override get message() { - return revertMessage("revm execute reverted", this.errorName); + return revertMessage("Mutation reverted", this); } } @@ -378,7 +398,7 @@ export class MutationHaltedError extends Data.TaggedError( reason: string; }> { override get message() { - return `revm execute halted: ${this.reason}`; + return `Mutation halted with ${this.reason}`; } } @@ -414,7 +434,7 @@ export function executeMutation(params: { new EncodeMutationError({ mutation: params.mutation, cause }), }); - const executeStartedAtMs = startTimer(); + const executeStartedAt = yield* startTimer; const executeResult = yield* params.evm.execute({ from: params.app.account.address, to: params.app.address, @@ -435,12 +455,12 @@ export function executeMutation(params: { journalId: executeResult.journal_id, }); - yield* Effect.logDebug("executed mutation").pipe( + yield* Effect.logDebug("Executed mutation").pipe( Effect.annotateLogs({ - executionIndex: acceptedMutation.executionIndex, - name: acceptedMutation.name, - slotWriteCount: executeResult.slot_writes.length, - duration: durationMs(executeStartedAtMs), + mutation: acceptedMutation.name, + execution_index: acceptedMutation.executionIndex, + slot_write_count: executeResult.slot_writes.length, + duration: yield* durationMs(executeStartedAt), }), ); @@ -495,7 +515,7 @@ export function decodeEnqueuedMutation(params: { ); if (mutationEntry === undefined) { throw new Error( - `unknown mutation tag in ForceInclusionQueued event: ${forceInclusionLog.mutation}`, + `Unknown mutation ID ${forceInclusionLog.mutation} in ForceInclusionQueued event`, ); } const [mutationName, mutationConfig] = mutationEntry; @@ -531,8 +551,13 @@ function decodeRevert(abi: Abi, data: Hex.Hex): Revert { } } -function revertMessage(prefix: string, errorName: string | undefined) { - return errorName === undefined ? prefix : `${prefix}: ${errorName}`; +/** `prefix`, and the decoded error with its arguments, like `Error("reason")`. */ +function revertMessage(prefix: string, revert: Revert) { + if (revert.errorName === undefined) return prefix; + const args = (revert.args ?? []) + .map((arg) => (typeof arg === "string" ? JSON.stringify(arg) : String(arg))) + .join(", "); + return `${prefix} with ${revert.errorName}(${args})`; } function createRuntimeState( @@ -574,9 +599,8 @@ function createRuntimeState( if (code === "0x") { yield* Effect.logWarning( - `no contract code at app.address=${app.address}; ` + - "revm code load skipped - runtime will continue but revm has no contract bytecode", - ); + "Detected no contract code at the typewriter address; mutations will execute without it", + ).pipe(Effect.annotateLogs({ address: app.address })); } const evm = yield* createEVM({ chain_id: app.chainId, @@ -875,7 +899,7 @@ export function createTypewriterEffect< ) => withSpeculativeStateLock( Effect.gen(function* () { - const batchStartedAtMs = startTimer(); + const batchStartedAt = yield* startTimer; if (submittedMutations.length === 0) { return; @@ -916,11 +940,11 @@ export function createTypewriterEffect< mutations: acceptedMutations.map(({ mutation }) => mutation), }; - yield* Effect.logDebug("batched mutations").pipe( + yield* Effect.logDebug("Created batch").pipe( Effect.annotateLogs({ - id: batch.id, - mutationCount: batch.mutations.length, - duration: durationMs(batchStartedAtMs), + batch_id: batch.id, + mutation_count: batch.mutations.length, + duration: yield* durationMs(batchStartedAt), }), ); @@ -1052,14 +1076,14 @@ export function createTypewriterEffect< acceptedForceIncludedMutations.map(({ queueIndex }) => queueIndex), ); - const simulationStartedAtMs = startTimer(); + const simulationStartedAt = yield* startTimer; const simulateResult = yield* evm.simulate({ from: app.account.address, to: app.address, data: calldata, journal_ids: speculativeJournalIds, }); - const duration = durationMs(simulationStartedAtMs); + const duration = yield* durationMs(simulationStartedAt); if (simulateResult.status === "revert") { return yield* new SettlementSimulationRevertedError( decodeRevert(app.abi, simulateResult.data), @@ -1071,12 +1095,12 @@ export function createTypewriterEffect< }); } - yield* Effect.logDebug("simulated transaction submission").pipe( + yield* Effect.logDebug("Simulated settlement transaction").pipe( Effect.annotateLogs({ nonce, - gasLimit: simulateResult.gas_limit, - batchCount: batches.length, - mutationCount: speculativeJournalIds.length, + gas_limit: simulateResult.gas_limit, + batch_count: batches.length, + mutation_count: speculativeJournalIds.length, duration, }), ); @@ -1103,7 +1127,7 @@ export function createTypewriterEffect< yield* Effect.forEach(commits, Deferred.await, { discard: true }); - const transactionStartedAtMs = startTimer(); + const transactionStartedAt = yield* startTimer; const filled = yield* rpc.request({ method: "eth_fillTransaction", @@ -1164,13 +1188,12 @@ export function createTypewriterEffect< transactionHash: receipt.transactionHash, }); } - const duration = durationMs(transactionStartedAtMs); + const duration = yield* durationMs(transactionStartedAt); - yield* Effect.logDebug("submitted transaction").pipe( + yield* Effect.logDebug("Submitted settlement transaction").pipe( Effect.annotateLogs({ - transactionHash: receipt.transactionHash, - blockHash: receipt.blockHash, - blockNumber: receipt.blockNumber, + transaction_hash: receipt.transactionHash, + block_number: receipt.blockNumber, duration, }), ); @@ -1328,7 +1351,7 @@ export function createTypewriterEffect< }), ), ); - }).pipe(Effect.withLogSpan("watch")); + }); const submitIntervalMs = app.sequencing.submitIntervalMs; @@ -1361,7 +1384,7 @@ export function createTypewriterEffect< const persistProgram = Effect.forever( Effect.gen(function* () { const jobs = yield* Queue.takeAll(persistQueue); - const persistStartedAtMs = startTimer(); + const persistStartedAt = yield* startTimer; yield* db.transaction((tx) => Effect.forEach(jobs, ({ write }) => write(tx), { discard: true }), ); @@ -1369,10 +1392,10 @@ export function createTypewriterEffect< yield* Deferred.succeed(committed, undefined); } - yield* Effect.logDebug("persisted runtime writes").pipe( + yield* Effect.logDebug("Persisted runtime writes").pipe( Effect.annotateLogs({ - jobCount: jobs.length, - duration: durationMs(persistStartedAtMs), + job_count: jobs.length, + duration: yield* durationMs(persistStartedAt), }), ); }), @@ -1394,7 +1417,6 @@ export function createTypewriterEffect< Effect.onError((cause) => Deferred.fail(stopped, new TypewriterStoppedError({ cause })), ), - Effect.provide(loggerLayer), ); function execute( @@ -1414,9 +1436,9 @@ export function createTypewriterEffect< config: resolvedMutation.config, } as const satisfies ReceivedMutation; - yield* Effect.logDebug("received mutation").pipe( + yield* Effect.logDebug("Received mutation").pipe( Effect.annotateLogs({ - name: runtimeMutation.name, + mutation: runtimeMutation.name, params: runtimeMutation.params, }), ); @@ -1427,7 +1449,7 @@ export function createTypewriterEffect< { mutation: AcceptedMutation; committed: Deferred.Deferred }, ExecuteMutationError >(); - const acceptStartedAtMs = startTimer(); + const acceptStartedAt = yield* startTimer; yield* Queue.offer(receivedMutationQueue, { mutation: runtimeMutation, @@ -1436,11 +1458,12 @@ export function createTypewriterEffect< const acceptedMutation = yield* Effect.gen(function* () { const { mutation, committed } = yield* Deferred.await(deferred).pipe( + // The caller gets the error, and a failure that stops the runtime is + // logged when it stops, so a rejection is only a debug log. Effect.tapError((error) => - Effect.logError(error).pipe( + Effect.logDebug("Rejected mutation", error).pipe( Effect.annotateLogs({ - message: "rejected mutation", - name: runtimeMutation.name, + mutation: runtimeMutation.name, params: runtimeMutation.params, }), ), @@ -1450,17 +1473,16 @@ export function createTypewriterEffect< return mutation; }).pipe(Effect.raceFirst(Deferred.await(stopped))); - yield* Effect.logDebug("accepted mutation").pipe( + yield* Effect.logDebug("Accepted mutation").pipe( Effect.annotateLogs({ - executionIndex: acceptedMutation.executionIndex, - name: runtimeMutation.name, - params: runtimeMutation.params, - duration: durationMs(acceptStartedAtMs), + mutation: runtimeMutation.name, + execution_index: acceptedMutation.executionIndex, + duration: yield* durationMs(acceptStartedAt), }), ); return { executionIndex: acceptedMutation.executionIndex }; - }).pipe(Effect.provide(loggerLayer)); + }); } function on( @@ -1503,10 +1525,7 @@ export function createTypewriterEffect< on, program, } as TypewriterEffect; - }).pipe( - Effect.tapError((error) => Effect.logError(error)), - Effect.provide(loggerLayer), - ); + }); } export function nextBlockStatus( diff --git a/packages/typewriter/src/watch.ts b/packages/typewriter/src/watch.ts index c154562..90beae9 100644 --- a/packages/typewriter/src/watch.ts +++ b/packages/typewriter/src/watch.ts @@ -70,7 +70,11 @@ export class UnrecoverableReorgError extends Data.TaggedError( )<{ number: bigint; hash: Hex.Hex; -}> {} +}> { + override get message() { + return `Detected unrecoverable reorg deeper than the local chain at block ${this.number} (${this.hash})`; + } +} export type WatchError = | RpcError @@ -129,7 +133,7 @@ export function makeWatch( block.logsBloom === null ) { return yield* new IncompleteBlockError({ - message: "block missing number or hash", + message: "Received block without a number or hash", }); } @@ -163,7 +167,7 @@ export function makeWatch( log.logIndex === null ) { return yield* new IncompleteBlockError({ - message: "log missing transaction hash or index", + message: "Received log without a transaction hash or index", }); } @@ -335,7 +339,7 @@ export function makeWatch( return yield* getLocalBlockWithLogs(block); }); yield* reconcileBlock(latestBlock); - }).pipe(Effect.withLogSpan("watch")); + }); return { messages: Stream.fromQueue(queue),