From 94e754772ec61cdc79cc55a49990ba581337bb9b Mon Sep 17 00:00:00 2001 From: kyscott18 <43524469+kyscott18@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:51:51 +0000 Subject: [PATCH 01/10] refactor(typewriter): parse apps from solc output, tested with inline Solidity Split Solidity parsing into a pure fundamental and a thin build step: - sol-parse.ts: parseSolidity(solcOutput, path) reads the app from solc's standard JSON output, with no IO. It matches dispatch branches and decodes by declaration ID instead of identifier names, so renamed dispatch params, reversed comparisons, `Mutation(mutation) == Mutation.X`, inline decodes, and an inherited `dispatch`/`Mutation` all work. Mutation params can now be nested structs, user-defined value types, and contracts, and `uint`/`int` canonicalize to uint256/int256. Duplicate branches are an error, and an app that links an external library is rejected (the old hex check was non-strict and never caught it). - sol-build.ts: loadSolidityApp runs forge with --build-info and passes the solc output to parseSolidity, replacing the artifact globbing and import-graph walk. - The tests compile inline Solidity with solc-js next to the real Typewriter.sol and check results and errors with inline snapshots. The forge fixture contracts are gone; test/contracts stays for Typewriter.t.sol. Co-Authored-By: Claude Opus 5.5 --- README.md | 12 +- bun.lock | 19 +- packages/typewriter/README.md | 12 +- packages/typewriter/package.json | 3 + packages/typewriter/simulation-test/index.ts | 19 +- packages/typewriter/src/config.test.ts | 2 +- packages/typewriter/src/index.ts | 16 +- packages/typewriter/src/sol-build.ts | 96 ++ packages/typewriter/src/sol-parse.test.ts | 776 ++++++++++---- packages/typewriter/src/sol-parse.ts | 987 +++++------------- .../typewriter/test/contracts/src/Counter.sol | 37 - .../contracts/src/ExtraConstructorArg.sol | 11 - .../typewriter/test/contracts/src/Harness.sol | 65 -- .../test/contracts/src/InvalidEntrypoint.sol | 4 - .../contracts/src/MultipleEntrypoints.sol | 8 - .../test/contracts/src/NestedParam.sol | 45 - 16 files changed, 973 insertions(+), 1139 deletions(-) create mode 100644 packages/typewriter/src/sol-build.ts delete mode 100644 packages/typewriter/test/contracts/src/Counter.sol delete mode 100644 packages/typewriter/test/contracts/src/ExtraConstructorArg.sol delete mode 100644 packages/typewriter/test/contracts/src/Harness.sol delete mode 100644 packages/typewriter/test/contracts/src/InvalidEntrypoint.sol delete mode 100644 packages/typewriter/test/contracts/src/MultipleEntrypoints.sol delete mode 100644 packages/typewriter/test/contracts/src/NestedParam.sol diff --git a/README.md b/README.md index 5bb5128c..3e9d6ddc 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,10 @@ struct State { Each mutation is a Solidity library with: - **`struct [Mutation]` definition**. The app's `dispatch` callback ABI-decodes `mutationData` into this struct. Field names and order define the params - extracted into the manifest. + extracted into the manifest. Fields can be any ABI type: value types, + `bytes`, `string`, arrays, and nested structs. Enums become `uint8`, + user-defined value types their underlying type, and contracts and interfaces + `address`. - **`execute` function**. Applies the mutation to app state using the authenticated `accountID`. Account lookup, permissions, expiration, nonce, and signature checks have already happened in the base contract. @@ -481,6 +484,13 @@ through the fixed authorization envelope, consumes the nonce, and calls mutation ID and ABI-decodes the mutation struct. Built-in account mutations are handled entirely by the base contract. +Typewriter reads each mutation's params from `dispatch`: every `Mutation` +member needs exactly one branch that compares the `mutation` argument with it, +as `if (mutation == uint8(Mutation.Add))`, and decodes `mutationData` into a +struct exactly once inside that branch. `dispatch` and `Mutation` can live in +a base contract. Library functions must be `internal`: typewriter executes only +the app contract, so it rejects an app that links external libraries. + #### Determinism Opcodes whose values can't be reproduced offchain produce divergence between offchain and onchain state, so they are incompatible with Typewriter: diff --git a/bun.lock b/bun.lock index 890ff50d..80e853e5 100644 --- a/bun.lock +++ b/bun.lock @@ -102,6 +102,9 @@ "typewriter-evm": "workspace:*", "viem": "catalog:", }, + "devDependencies": { + "solc": "0.8.37", + }, "peerDependencies": { "typescript": "catalog:", }, @@ -513,6 +516,10 @@ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + "command-exists": ["command-exists@1.2.9", "", {}, "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w=="], + + "commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="], @@ -587,12 +594,16 @@ "isows": ["isows@1.0.7", "", { "peerDependencies": { "ws": "*" } }, "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg=="], + "js-sha3": ["js-sha3@0.8.0", "", {}, "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q=="], + "js-yaml": ["js-yaml@5.4.3", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.mjs" } }, "sha512-yzzNtgczwgoVHNigCyKTXcQJU4Y8t/ikU4UxXW0BvrLfHTGWZhsNcOT2s/a7vn81/4KqVnNmHjaUEPlvWnNwVw=="], "json-with-bigint": ["json-with-bigint@3.5.8", "", {}, "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw=="], "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + "memorystream": ["memorystream@0.3.1", "", {}, "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw=="], + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], @@ -653,7 +664,7 @@ "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], - "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -663,6 +674,8 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "solc": ["solc@0.8.37", "", { "dependencies": { "command-exists": "^1.2.8", "commander": "^8.1.0", "follow-redirects": "^1.12.1", "js-sha3": "0.8.0", "memorystream": "^0.3.1", "semver": "^5.5.0", "tmp": "0.2.6" }, "bin": { "solcjs": "solc.js" } }, "sha512-ieUYdPDRGNKMsZpCfi9tY64Q+ASoL8tyhLuvf9cFiqDs+6ijlx8Wn2CRi0N71AlCvRx0V092rlUYu3b3PuxWlA=="], + "solmate": ["solmate@github:transmissions11/solmate#89365b8", {}, "transmissions11-solmate-89365b8", "sha512-CVOwjPtEb1ihsvJiL0h1PMjvzn6sSWQmGmEohVkgqg/c7bzGpaoz/rgYCpK+9e4EtNgGo/4jdX1tMLB4Cg+heg=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -685,6 +698,8 @@ "tar": ["tar@7.2.0", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.0", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-hctwP0Nb4AB60bj8WQgRYaMOuJYRAPMGiQUAotms5igN8ppfQM+IvjQ5HcKu1MaZh2Wy2KWVTe563Yj8dfc14w=="], + "tmp": ["tmp@0.2.6", "", {}, "sha512-5sJPdPjfI5Kx+qbrDesxkglRBxW//g7hCsqspEjwkewGvBMGIKMOTKzLt1hFVJzyadba3lDUN20O9qhvbQUSTA=="], + "token": ["token@workspace:apps/token"], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], @@ -743,6 +758,8 @@ "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + "@napi-rs/cli/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "@napi-rs/cli/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "@noble/post-quantum/@noble/curves": ["@noble/curves@2.2.0", "", { "dependencies": { "@noble/hashes": "2.2.0" } }, "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ=="], diff --git a/packages/typewriter/README.md b/packages/typewriter/README.md index 9c08ce1e..d5e379d0 100644 --- a/packages/typewriter/README.md +++ b/packages/typewriter/README.md @@ -240,7 +240,10 @@ struct State { Each mutation is a Solidity library with: - **`struct [Mutation]` definition**. The app's `dispatch` callback ABI-decodes `mutationData` into this struct. Field names and order define the params - extracted into the manifest. + extracted into the manifest. Fields can be any ABI type: value types, + `bytes`, `string`, arrays, and nested structs. Enums become `uint8`, + user-defined value types their underlying type, and contracts and interfaces + `address`. - **`execute` function**. Applies the mutation to app state using the authenticated `accountID`. Account lookup, permissions, expiration, nonce, and signature checks have already happened in the base contract. @@ -479,6 +482,13 @@ through the fixed authorization envelope, consumes the nonce, and calls mutation ID and ABI-decodes the mutation struct. Built-in account mutations are handled entirely by the base contract. +Typewriter reads each mutation's params from `dispatch`: every `Mutation` +member needs exactly one branch that compares the `mutation` argument with it, +as `if (mutation == uint8(Mutation.Add))`, and decodes `mutationData` into a +struct exactly once inside that branch. `dispatch` and `Mutation` can live in +a base contract. Library functions must be `internal`: typewriter executes only +the app contract, so it rejects an app that links external libraries. + #### Determinism Opcodes whose values can't be reproduced offchain produce divergence between offchain and onchain state, so they are incompatible with Typewriter: diff --git a/packages/typewriter/package.json b/packages/typewriter/package.json index 05be78ba..dbff371f 100644 --- a/packages/typewriter/package.json +++ b/packages/typewriter/package.json @@ -25,5 +25,8 @@ }, "peerDependencies": { "typescript": "catalog:" + }, + "devDependencies": { + "solc": "0.8.37" } } diff --git a/packages/typewriter/simulation-test/index.ts b/packages/typewriter/simulation-test/index.ts index 5fd6e6d2..c23374cd 100644 --- a/packages/typewriter/simulation-test/index.ts +++ b/packages/typewriter/simulation-test/index.ts @@ -19,14 +19,11 @@ import { Hash, Hex } from "ox"; import { decodeEventLog } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { KeyType } from "../src/client"; -import type { TypewriterManifest } from "../src/config"; +import { createTypewriterApp, type TypewriterManifest } from "../src/config"; import { Database, makeDb } from "../src/db"; import { logger, loggerLayer } from "../src/logger"; import { layerRpc } from "../src/rpc"; -import { - createSolidityTypewriterApp, - loadSolidityMetadata, -} from "../src/sol-parse"; +import { loadSolidityApp } from "../src/sol-build"; import { createTypewriterEffect, type ExecuteError, @@ -310,11 +307,12 @@ const program = Effect.gen(function* () { yield* Effect.logInfo("Created simulation database").pipe( Effect.annotateLogs({ database: database.name }), ); - const metadata = yield* Effect.promise(() => - loadSolidityMetadata(ASSESSMENT_ENTRYPOINT), + const solidityApp = yield* Effect.promise(() => + loadSolidityApp(ASSESSMENT_ENTRYPOINT), ); - const app = createSolidityTypewriterApp( - { + const app = createTypewriterApp({ + ...solidityApp, + config: { address, account: SCHEDULER, chainId, @@ -334,8 +332,7 @@ const program = Effect.gen(function* () { submitIntervalMs: SIM.typewriter.submitIntervalMs, }, }, - metadata, - ); + }); const manifest = app.manifest as TypewriterManifest; // One connection runs the statements in the order `io` starts them, so // Postgres sees no race that depends on wall-clock time. diff --git a/packages/typewriter/src/config.test.ts b/packages/typewriter/src/config.test.ts index 1749776c..33f4ddf8 100644 --- a/packages/typewriter/src/config.test.ts +++ b/packages/typewriter/src/config.test.ts @@ -17,7 +17,7 @@ import type { Typewriter, TypewriterMutationInput, } from "./index"; -import type { TypewriterSolidityEntrypoint } from "./sol-parse"; +import type { TypewriterSolidityEntrypoint } from "./sol-build"; import { BUILTIN_MUTATIONS } from "./typewriter-constants"; const STORAGE_LAYOUT = { diff --git a/packages/typewriter/src/index.ts b/packages/typewriter/src/index.ts index 90a30a19..6c33bfdc 100644 --- a/packages/typewriter/src/index.ts +++ b/packages/typewriter/src/index.ts @@ -2,6 +2,7 @@ import { BunRuntime, BunServices } from "@effect/platform-bun"; import { Cause, Deferred, Effect, Layer } from "effect"; import { startDevChain } from "./anvil"; import { + createTypewriterApp, type MutationConfig, type MutationsConfig, type SequencingConfig, @@ -14,10 +15,9 @@ import { loggerLayer } from "./logger"; import { layerRpc } from "./rpc"; import { makeHttpTransport } from "./rpc-transport"; import { - createSolidityTypewriterApp, - loadSolidityMetadata, + loadSolidityApp, type TypewriterSolidityEntrypoint, -} from "./sol-parse"; +} from "./sol-build"; import { createTypewriterEffect, type TypewriterEffect } from "./typewriter"; import type { BatchListener, @@ -61,7 +61,7 @@ export { RpcResponseError, type RpcTransportError, } from "./rpc-transport"; -export type { TypewriterSolidityEntrypoint } from "./sol-parse"; +export type { TypewriterSolidityEntrypoint } from "./sol-build"; export { ChainIdMismatchError, type CreateTypewriterError, @@ -158,16 +158,16 @@ export function createTypewriter< try: () => validateConfig(config), catch: (error) => error, }); - const metadata = yield* Effect.tryPromise({ - try: () => loadSolidityMetadata(entrypoint), + const solidityApp = yield* Effect.tryPromise({ + try: () => loadSolidityApp(entrypoint), catch: (error) => error, }); const devChain = config.dev === true - ? yield* startDevChain(metadata.bytecode) + ? yield* startDevChain(solidityApp.bytecode) : undefined; const app = yield* Effect.try({ - try: () => createSolidityTypewriterApp(config, metadata, devChain), + try: () => createTypewriterApp({ config, ...solidityApp, devChain }), catch: (error) => error, }); const [rpcUrl, ...rpcUrls] = app.rpcUrls; diff --git a/packages/typewriter/src/sol-build.ts b/packages/typewriter/src/sol-build.ts new file mode 100644 index 00000000..7521de88 --- /dev/null +++ b/packages/typewriter/src/sol-build.ts @@ -0,0 +1,96 @@ +import { mkdtemp, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { $ } from "bun"; +import { Predicate } from "effect"; +import { ConfigError } from "./config"; +import { + formatSolidityDeclaration, + parseSolidity, + type SolcOutput, + type SolidityApp, +} from "./sol-parse"; + +export type TypewriterSolidityEntrypoint = string & { + readonly __typewriterSolidityEntrypoint?: metadata; +}; + +/** + * Builds the app's Foundry project, reads the app from the compiler output, + * and refreshes its generated declaration. + */ +export async function loadSolidityApp( + entrypoint: TypewriterSolidityEntrypoint, +): Promise { + // Apps cast the `.sol` import, so it may not be a path at runtime. + const value = entrypoint as unknown; + if (typeof value !== "string" || !(await Bun.file(value).exists())) { + throw new ConfigError({ + message: `Typewriter entrypoint must be the path of a Solidity file, but is ${JSON.stringify(value)}; import the contract's .sol file and pass it as the first argument`, + }); + } + const path = resolve(value); + const root = await findUp(dirname(path), "foundry.toml"); + if (root === undefined) { + throw new ConfigError({ + message: `Could not find foundry.toml for ${path}`, + }); + } + + // A new directory per build, so concurrent builds don't share artifacts. + const dir = await mkdtemp(join(tmpdir(), "typewriter-forge-")); + try { + try { + await $`forge build --ast --extra-output storageLayout --build-info --build-info-path ${join(dir, "build-info")} --out ${join(dir, "out")} --cache-path ${join(dir, "cache")}` + .cwd(root) + .quiet(); + } catch (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 ${root}${output === "" ? "" : `\n${output}`}`, + cause, + }); + } + + for (const file of await readdir(join(dir, "build-info"))) { + const { output } = (await Bun.file( + join(dir, "build-info", file), + ).json()) as { output: SolcOutput }; + const source = Object.keys(output.sources).find( + (source) => resolve(root, source) === path, + ); + if (source === undefined) continue; + const app = parseSolidity(output, source); + // Only apps that declare `*.sol` imports read the generated types. + const pkg = await findUp(dirname(path), "package.json"); + if ( + pkg !== undefined && + (await Bun.file(join(pkg, "src", "typewriter.d.ts")).exists()) + ) { + await writeIfChanged(`${path}.d.ts`, formatSolidityDeclaration(app)); + } + return app; + } + throw new ConfigError({ message: `forge did not compile ${path}` }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** The nearest directory from `dir` up that contains `file`. */ +async function findUp(dir: string, file: string): Promise { + if (await Bun.file(join(dir, file)).exists()) return dir; + const parent = dirname(dir); + return parent === dir ? undefined : findUp(parent, file); +} + +async function writeIfChanged(path: string, contents: string): Promise { + const file = Bun.file(path); + if ((await file.exists()) && (await file.text()) === contents) return; + await Bun.write(path, contents); +} diff --git a/packages/typewriter/src/sol-parse.test.ts b/packages/typewriter/src/sol-parse.test.ts index 45201b05..ede3cd1a 100644 --- a/packages/typewriter/src/sol-parse.test.ts +++ b/packages/typewriter/src/sol-parse.test.ts @@ -1,261 +1,623 @@ import { expect, test } from "bun:test"; -import Token from "../../../apps/token/contracts/src/Token.sol"; -import Counter from "../test/contracts/src/Counter.sol"; -import ExtraConstructorArg from "../test/contracts/src/ExtraConstructorArg.sol"; -import Harness from "../test/contracts/src/Harness.sol"; -import InvalidEntrypoint from "../test/contracts/src/InvalidEntrypoint.sol"; -import MultipleEntrypoints from "../test/contracts/src/MultipleEntrypoints.sol"; -import NestedParam from "../test/contracts/src/NestedParam.sol"; -import { formatSolidityDeclaration, parseSolidityMetadata } from "./sol-parse"; - -function summarizeMutations( - mutations: Awaited>["mutations"], -) { - return mutations.map((mutation) => ({ - name: mutation.enumName, - id: mutation.id, - params: mutation.params.map((param) => `${param.name}:${param.type}`), - })); -} +import solc from "solc"; +import { + formatSolidityDeclaration, + parseSolidity, + type SolcOutput, +} from "./sol-parse"; +import { BUILTIN_MUTATIONS } from "./typewriter-constants"; -test("parseSolidityMetadata extracts Counter native-account metadata", async () => { - const metadata = await parseSolidityMetadata(Counter); +const TYPEWRITER = await Bun.file( + new URL("./Typewriter.sol", import.meta.url), +).text(); - expect({ - contractName: metadata.contractName, - storage: metadata.storageLayout.storage.map( - (entry) => `${entry.label}:${entry.slot}`, - ), - mutations: summarizeMutations(metadata.mutations), - }).toMatchInlineSnapshot(` - { - "contractName": "Counter", - "mutations": [ - { - "id": 0, - "name": "Add", - "params": [ - "amount:uint256", - ], +/** Compiles `source` as App.sol, next to Typewriter.sol, with solc. */ +function compile(source: string): SolcOutput { + const output = JSON.parse( + solc.compile( + JSON.stringify({ + language: "Solidity", + sources: { + "Typewriter.sol": { content: TYPEWRITER }, + "App.sol": { + content: `// SPDX-License-Identifier: MIT\npragma solidity ^0.8.21;\nimport "Typewriter.sol";\n${source}`, + }, }, - { - "id": 253, - "name": "CreateAccount", - "params": [ - "keyType:uint8", - "publicKey:bytes", - ], - }, - { - "id": 254, - "name": "AddCredential", - "params": [ - "expiration:uint40", - "keyType:uint8", - "permissions:uint256", - "publicKey:bytes", - ], - }, - { - "id": 255, - "name": "RemoveCredential", - "params": [ - "credentialID:uint64", - ], + settings: { + outputSelection: { + "*": { + "": ["ast"], + "*": ["abi", "storageLayout", "evm.bytecode.object"], + }, + }, }, - ], - "storage": [ - "accounts:0", - "queue:1", - "executionIndex:2", - "state:3", - ], + }), + ), + ); + const errors = ( + output.errors as { severity: string; formattedMessage: string }[] + )?.filter(({ severity }) => severity === "error"); + if (errors?.length) { + throw new Error(errors.map((error) => error.formattedMessage).join("\n")); + } + return output; +} + +function parse(source: string) { + return parseSolidity(compile(source), "App.sol"); +} + +/** The app's mutations, as `Name(id): type name, ...`. */ +function mutations(source: string): string[] { + return Object.entries(parse(source).mutations) + .filter(([name]) => !Object.hasOwn(BUILTIN_MUTATIONS, name)) + .map( + ([name, { id, params }]) => + `${name}(${id}): ${params.map(({ name, type }) => `${type} ${name}`).join(", ")}`, + ); +} + +/** A contract with `state`, the scheduler constructor, and `body`. */ +function app(body: string, name = "App"): string { + return ` +contract ${name} is Typewriter { + struct State { uint256 total; } + State internal state; + constructor(address scheduler) Typewriter(scheduler) {} + ${body} +}`; +} + +const COUNTER = ` +struct State { + uint256 total; +} + +library AddMutation { + struct Add { + uint256 amount; } - `); -}); +} + +library SubMutation { + struct Sub { + uint256 amount; + } +} -test("parseSolidityMetadata extracts account-scoped Harness metadata", async () => { - const metadata = await parseSolidityMetadata(Harness); +contract Counter is Typewriter { + State internal state; - expect({ - contractName: metadata.contractName, - storageLabels: metadata.storageLayout.storage.map((entry) => entry.label), - mutations: summarizeMutations(metadata.mutations), - }).toMatchInlineSnapshot(` + enum Mutation { + Add, + Sub + } + + constructor(address scheduler) Typewriter(scheduler) {} + + function dispatch(uint8 mutation, bytes memory mutationData, bytes32) internal override { + if (mutation == uint8(Mutation.Add)) { + AddMutation.Add memory add = abi.decode(mutationData, (AddMutation.Add)); + state.total += add.amount; + } else if (mutation == uint8(Mutation.Sub)) { + SubMutation.Sub memory sub = abi.decode(mutationData, (SubMutation.Sub)); + state.total -= sub.amount; + } else { + revert UnknownMutation(mutation); + } + } +}`; + +test("parseSolidity reads the app contract", () => { + const output = compile(COUNTER); + const { abi, bytecode, storageLayout, ...rest } = parseSolidity( + output, + "App.sol", + ); + + const { Counter } = output.contracts["App.sol"]!; + expect(abi).toEqual(Counter!.abi); + expect(bytecode).toBe(`0x${Counter!.evm.bytecode.object}`); + expect(storageLayout).toEqual(Counter!.storageLayout); + expect(rest).toMatchInlineSnapshot(` { - "contractName": "Harness", - "mutations": [ - { + "contractName": "Counter", + "mutations": { + "Add": { "id": 0, - "name": "Credit", "params": [ - "amount:uint256", + { + "name": "amount", + "type": "uint256", + }, ], }, - { - "id": 1, - "name": "Debit", - "params": [ - "amount:uint256", - ], - }, - { - "id": 2, - "name": "Assert", + "AddCredential": { + "id": 254, "params": [ - "expected:uint256", + { + "name": "expiration", + "type": "uint40", + }, + { + "name": "keyType", + "type": "uint8", + }, + { + "name": "permissions", + "type": "uint256", + }, + { + "name": "publicKey", + "type": "bytes", + }, ], }, - { + "CreateAccount": { "id": 253, - "name": "CreateAccount", "params": [ - "keyType:uint8", - "publicKey:bytes", + { + "name": "keyType", + "type": "uint8", + }, + { + "name": "publicKey", + "type": "bytes", + }, ], }, - { - "id": 254, - "name": "AddCredential", + "RemoveCredential": { + "id": 255, "params": [ - "expiration:uint40", - "keyType:uint8", - "permissions:uint256", - "publicKey:bytes", + { + "name": "credentialID", + "type": "uint64", + }, ], }, - { - "id": 255, - "name": "RemoveCredential", + "Sub": { + "id": 1, "params": [ - "credentialID:uint64", + { + "name": "amount", + "type": "uint256", + }, ], }, - ], - "storageLabels": [ - "accounts", - "queue", - "executionIndex", - "state", - ], + }, } `); }); -test("parseSolidityMetadata extracts current Token metadata", async () => { - const metadata = await parseSolidityMetadata(Token); +test("parseSolidity reads every ABI type a mutation param can have", () => { + expect( + mutations(` +type Price is uint128; +interface Token {} +enum Side { Buy, Sell } +uint256 constant SIZE = 3; - expect({ - contractName: metadata.contractName, - storageLabels: metadata.storageLayout.storage.map((entry) => entry.label), - mutations: summarizeMutations(metadata.mutations), - }).toMatchInlineSnapshot(` - { - "contractName": "Token", - "mutations": [ - { - "id": 0, - "name": "Transfer", - "params": [ - "to:bytes32", - "amount:uint256", - ], - }, - { - "id": 1, - "name": "Mint", - "params": [ - "amount:uint256", - ], - }, - { - "id": 253, - "name": "CreateAccount", - "params": [ - "keyType:uint8", - "publicKey:bytes", - ], - }, - { - "id": 254, - "name": "AddCredential", - "params": [ - "expiration:uint40", - "keyType:uint8", - "permissions:uint256", - "publicKey:bytes", - ], - }, - { - "id": 255, - "name": "RemoveCredential", - "params": [ - "credentialID:uint64", - ], - }, - ], - "storageLabels": [ - "accounts", - "queue", - "executionIndex", - "state", - ], +struct Leg { + Side side; + Price price; +} + +struct Order { + uint amount; + int delta; + address payable to; + bool flag; + bytes4 selector; + bytes data; + string memo; + Token token; + Side side; + Price price; + uint256[SIZE] fixedSize; + uint8[2][] nested; + Leg leg; + Leg[] legs; +} +${app(` + enum Mutation { Place } + + function dispatch(uint8 mutation, bytes memory mutationData, bytes32) internal override { + if (mutation == uint8(Mutation.Place)) abi.decode(mutationData, (Order)); + }`)}`), + ).toMatchInlineSnapshot(` + [ + "Place(0): uint256 amount, int256 delta, address to, bool flag, bytes4 selector, bytes data, string memo, address token, uint8 side, uint128 price, uint256[3] fixedSize, uint8[2][] nested, tuple leg, tuple[] legs", + ] + `); + const { Place } = parse(` +struct Inner { uint8 side; uint128 price; } +struct Outer { Inner leg; Inner[] legs; } +${app(` + enum Mutation { Place } + + function dispatch(uint8 mutation, bytes memory mutationData, bytes32) internal override { + if (mutation == uint8(Mutation.Place)) abi.decode(mutationData, (Outer)); + }`)}`).mutations; + expect(Place).toMatchInlineSnapshot(` + { + "id": 0, + "params": [ + { + "components": [ + { + "name": "side", + "type": "uint8", + }, + { + "name": "price", + "type": "uint128", + }, + ], + "name": "leg", + "type": "tuple", + }, + { + "components": [ + { + "name": "side", + "type": "uint8", + }, + { + "name": "price", + "type": "uint128", + }, + ], + "name": "legs", + "type": "tuple[]", + }, + ], + } + `); +}); + +test("parseSolidity reads the ways dispatch can branch and decode", () => { + expect( + mutations(` +struct A { uint256 a; } +struct B { uint256 b; } +struct C { uint256 c; } +struct D { uint256 d; } + +${app(` + enum Mutation { A, B, C, D } + + function applyC(C memory) internal {} + + function dispatch(uint8 id, bytes memory input, bytes32) internal override { + if (uint8(Mutation.A) == id) { + A memory a = abi.decode(input, (A)); + state.total += a.a; + } + if (Mutation(id) == Mutation.B) { + if (state.total > 0) { + state.total += abi.decode(input, (B)).b; + } + } else if (id == uint8(Mutation.C)) applyC(abi.decode(input, (C))); + else if (id == uint8(Mutation.D)) { + D memory d = abi.decode(input, (D)); + state.total = d.d; + } + }`)}`), + ).toMatchInlineSnapshot(` + [ + "A(0): uint256 a", + "B(1): uint256 b", + "C(2): uint256 c", + "D(3): uint256 d", + ] + `); +}); + +test("parseSolidity reads Mutation and dispatch from a base contract", () => { + expect( + mutations(` +import {Typewriter as Base} from "Typewriter.sol"; + +struct Add { uint256 amount; } + +abstract contract Counter is Base { + enum Mutation { Add } + + function dispatch(uint8 mutation, bytes memory mutationData, bytes32) internal override { + if (mutation == uint8(Mutation.Add)) abi.decode(mutationData, (Add)); } +} + +contract Helper {} + +contract App is Counter { + struct State { uint256 total; } + State internal state; + constructor(address scheduler) Base(scheduler) {} +}`), + ).toMatchInlineSnapshot(` + [ + "Add(0): uint256 amount", + ] `); -}, 20_000); - -test("generated Token Solidity declaration matches committed artifact", async () => { - const metadata = await parseSolidityMetadata(Token); - const declaration = await Bun.file( - new URL( - "../../../apps/token/contracts/src/Token.sol.d.ts", - import.meta.url, - ), - ).text(); +}); + +test("parseSolidity errors when the file defines no app contract", () => { + expect(() => + parse(` +abstract contract Base is Typewriter {} +contract Other {}`), + ).toThrowErrorMatchingInlineSnapshot( + `"App.sol must define one contract that inherits Typewriter and isn't abstract, but defines none"`, + ); +}); + +test("parseSolidity errors when the file defines more than one app contract", () => { + const body = ` + enum Mutation { Add } + struct Add { uint256 amount; } + + function dispatch(uint8 mutation, bytes memory mutationData, bytes32) internal override { + if (mutation == uint8(Mutation.Add)) abi.decode(mutationData, (Add)); + }`; + expect(() => + parse(`${app(body, "First")}${app(body, "Second")}`), + ).toThrowErrorMatchingInlineSnapshot( + `"App.sol must define one contract that inherits Typewriter and isn't abstract, but defines First, Second"`, + ); +}); + +test("parseSolidity errors when the path was not compiled", () => { + expect(() => + parseSolidity(compile(COUNTER), "Missing.sol"), + ).toThrowErrorMatchingInlineSnapshot(`"Missing.sol was not compiled"`); +}); + +test("parseSolidity errors when the constructor takes more than the scheduler", () => { + expect(() => + parse(` +contract App is Typewriter { + constructor(address scheduler, uint256) Typewriter(scheduler) {} + function dispatch(uint8, bytes memory, bytes32) internal override {} +}`), + ).toThrowErrorMatchingInlineSnapshot( + `"App must declare \`constructor(address scheduler)\` and pass it to \`Typewriter(scheduler)\`, but its constructor takes (address, uint256)"`, + ); +}); + +test("parseSolidity errors when state is not a struct", () => { + expect(() => + parse(` +contract App is Typewriter { + uint256 internal state; + constructor(address scheduler) Typewriter(scheduler) {} + function dispatch(uint8, bytes memory, bytes32) internal override {} +}`), + ).toThrowErrorMatchingInlineSnapshot( + `"App must keep its state in a struct variable named \`state\`"`, + ); +}); - expect(formatSolidityDeclaration(metadata)).toBe(declaration); -}, 20_000); +test("parseSolidity errors when the app calls an external library", () => { + expect(() => + parse(` +library Math { + function double(uint256 x) external pure returns (uint256) { return 2 * x; } +} +${app(` + enum Mutation { Add } + struct Add { uint256 amount; } -test("parseSolidityMetadata includes the creation bytecode", async () => { - const metadata = await parseSolidityMetadata(Counter); + function dispatch(uint8 mutation, bytes memory mutationData, bytes32) internal override { + if (mutation == uint8(Mutation.Add)) { + state.total += Math.double(abi.decode(mutationData, (Add)).amount); + } + }`)}`), + ).toThrowErrorMatchingInlineSnapshot( + `"App calls an external library function, which typewriter can't execute offchain; make the library's functions internal"`, + ); +}); - expect(metadata.bytecode).toMatch(/^0x60[0-9a-f]+$/); +test("parseSolidity errors when the app has no Mutation enum", () => { + expect(() => + parse( + app( + "function dispatch(uint8, bytes memory, bytes32) internal override {}", + ), + ), + ).toThrowErrorMatchingInlineSnapshot( + `"App must declare \`enum Mutation\` with a member for each mutation"`, + ); }); -test("parseSolidityMetadata errors when the constructor takes more than the scheduler", async () => { - await expect(parseSolidityMetadata(ExtraConstructorArg)).rejects.toThrow( - "ExtraConstructorArg must declare `constructor(address scheduler)` and pass it to `Typewriter(scheduler)`, but its constructor takes (address, uint256)", +test("parseSolidity errors when a mutation has a built-in name", () => { + expect(() => + parse( + app(` + enum Mutation { CreateAccount } + function dispatch(uint8, bytes memory, bytes32) internal override {}`), + ), + ).toThrowErrorMatchingInlineSnapshot( + `"Mutation.CreateAccount is reserved by typewriter; rename it"`, ); }); -test("parseSolidityMetadata errors when no contract inherits Typewriter", async () => { - await expect(parseSolidityMetadata(InvalidEntrypoint)).rejects.toThrow( - "InvalidEntrypoint.sol does not contain a contract inheriting Typewriter", +test("parseSolidity errors when a mutation has a built-in ID", () => { + const members = Array.from({ length: 254 }, (_, i) => `M${i}`); + const branches = members.map( + (member) => + `if (mutation == uint8(Mutation.${member})) abi.decode(mutationData, (Params));`, + ); + expect(() => + parse( + app(` + struct Params { uint256 value; } + enum Mutation { ${members.join(", ")} } + function dispatch(uint8 mutation, bytes memory mutationData, bytes32) internal override { + ${branches.join("\n ")} + }`), + ), + ).toThrowErrorMatchingInlineSnapshot( + `"Mutation.M253 has ID 253, but typewriter reserves IDs from 253; Mutation can have at most 253 members"`, ); }); -test("parseSolidityMetadata errors when multiple contracts inherit Typewriter", async () => { - await expect(parseSolidityMetadata(MultipleEntrypoints)).rejects.toThrow( - "MultipleEntrypoints.sol contains multiple contracts inheriting Typewriter: FirstEntrypoint, SecondEntrypoint", +test("parseSolidity errors when a mutation has no dispatch branch", () => { + expect(() => + parse( + app(` + enum Mutation { Add, Sub } + struct Add { uint256 amount; } + + function dispatch(uint8 mutation, bytes memory mutationData, bytes32) internal override { + if (mutation == uint8(Mutation.Add)) abi.decode(mutationData, (Add)); + }`), + ), + ).toThrowErrorMatchingInlineSnapshot( + `"App.dispatch has no branch for Mutation.Sub; add \`if (mutation == uint8(Mutation.Sub))\`"`, ); }); -test("parseSolidityMetadata errors when the entrypoint is not a string", async () => { - await expect( - parseSolidityMetadata(42 as unknown as typeof Counter), - ).rejects.toThrow( - "Typewriter entrypoint must be a Solidity file path string", +test("parseSolidity errors when a mutation has two dispatch branches", () => { + expect(() => + parse( + app(` + enum Mutation { Add } + struct Add { uint256 amount; } + + function dispatch(uint8 mutation, bytes memory mutationData, bytes32) internal override { + if (mutation == uint8(Mutation.Add)) abi.decode(mutationData, (Add)); + if (mutation == uint8(Mutation.Add)) abi.decode(mutationData, (Add)); + }`), + ), + ).toThrowErrorMatchingInlineSnapshot( + `"App.dispatch has more than one branch for Mutation.Add"`, ); }); -test("parseSolidityMetadata errors when the entrypoint file does not exist", async () => { - await expect( - parseSolidityMetadata("/tmp/does-not-exist/Missing.sol" as typeof Counter), - ).rejects.toThrow("Typewriter entrypoint file does not exist"); +test("parseSolidity errors when a branch doesn't decode one struct", () => { + const dispatch = (branch: string) => + app(` + enum Mutation { Add } + struct Add { uint256 amount; } + + function dispatch(uint8 mutation, bytes memory mutationData, bytes32) internal override { + if (mutation == uint8(Mutation.Add)) { + ${branch} + } + }`); + + const error = (branch: string) => { + try { + parse(dispatch(branch)); + } catch (error) { + return (error as Error).message; + } + }; + expect({ + none: error("state.total += 1;"), + twice: error( + "abi.decode(mutationData, (Add)); abi.decode(mutationData, (Add));", + ), + elementary: error("abi.decode(mutationData, (uint256));"), + tuple: error("abi.decode(mutationData, (Add, uint256));"), + otherBytes: error("abi.decode(abi.encode(1), (Add));"), + }).toMatchInlineSnapshot(` + { + "elementary": "The Mutation.Add branch of App.dispatch must decode mutationData into a struct once, as \`abi.decode(mutationData, (Add))\`", + "none": "The Mutation.Add branch of App.dispatch must decode mutationData into a struct once, as \`abi.decode(mutationData, (Add))\`", + "otherBytes": "The Mutation.Add branch of App.dispatch must decode mutationData into a struct once, as \`abi.decode(mutationData, (Add))\`", + "tuple": "The Mutation.Add branch of App.dispatch must decode mutationData into a struct once, as \`abi.decode(mutationData, (Add))\`", + "twice": "The Mutation.Add branch of App.dispatch must decode mutationData into a struct once, as \`abi.decode(mutationData, (Add))\`", + } + `); }); -test("parseSolidityMetadata rejects nested struct mutation params", async () => { - await expect(parseSolidityMetadata(NestedParam)).rejects.toThrow( - "Unsupported Solidity type", +test("parseSolidity errors when a param has no ABI type", () => { + expect(() => + parse(` +struct Callback { function() external fn; } +${app(` + enum Mutation { Call } + + function dispatch(uint8 mutation, bytes memory mutationData, bytes32) internal override { + if (mutation == uint8(Mutation.Call)) abi.decode(mutationData, (Callback)); + }`)}`), + ).toThrowErrorMatchingInlineSnapshot( + `"Callback.fn has type function () external, which can't be a mutation param"`, ); }); + +test("formatSolidityDeclaration types the entrypoint import", () => { + expect( + formatSolidityDeclaration({ + contractName: "Counter", + abi: [], + bytecode: "0x", + storageLayout: { + storage: [ + { + astId: 1, + contract: "src/Counter.sol:Counter", + label: "state", + offset: 0, + slot: "0", + type: "t_uint256", + }, + ], + types: { + t_uint256: { + encoding: "inplace", + label: "uint256", + numberOfBytes: "32", + }, + }, + }, + mutations: { + Add: { id: 0, params: [{ name: "amount", type: "uint256" }] }, + Reset: { id: 1, params: [] }, + }, + }), + ).toMatchInlineSnapshot(` + "// Generated by typewriter from Counter.sol. Do not edit by hand. + + declare const entrypoint: import("typewriter").TypewriterSolidityEntrypoint<{ + readonly storageLayout: { + readonly storage: readonly [ + { + readonly astId: 1; + readonly contract: "src/Counter.sol:Counter"; + readonly label: "state"; + readonly offset: 0; + readonly slot: "0"; + readonly type: "t_uint256"; + }, + ]; + readonly types: { + readonly t_uint256: { + readonly encoding: "inplace"; + readonly label: "uint256"; + readonly numberOfBytes: "32"; + }; + }; + }; + readonly mutations: { + readonly Add: { + readonly id: 0; + readonly params: readonly [ + { + readonly name: "amount"; + readonly type: "uint256"; + }, + ]; + }; + readonly Reset: { + readonly id: 1; + readonly params: readonly []; + }; + }; + }>; + export default entrypoint; + " + `); +}); diff --git a/packages/typewriter/src/sol-parse.ts b/packages/typewriter/src/sol-parse.ts index 9bcad521..7eefa727 100644 --- a/packages/typewriter/src/sol-parse.ts +++ b/packages/typewriter/src/sol-parse.ts @@ -1,829 +1,338 @@ -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { $ } from "bun"; -import { Predicate } from "effect"; import { Hex } from "ox"; import type { Abi, AbiParameter } from "viem"; -import type { - ResolvedTypewriterMutationConfig, - SequencingConfig, - StorageConfig, - TypewriterConfig, -} from "./config"; import { ConfigError, - createTypewriterApp, - type DevChain, - type TypewriterApp, + type ResolvedTypewriterMutationConfig, + type StorageConfig, } from "./config"; import { BUILTIN_MUTATIONS, FIRST_BUILTIN_MUTATION_ID, } from "./typewriter-constants"; -type JsonObject = Record; - -type AstNode = JsonObject & { - readonly id?: number; - readonly name?: string; - readonly nodeType?: string; - readonly src?: string; - readonly absolutePath?: string; - readonly arguments?: readonly AstNode[]; - readonly baseContracts?: readonly AstNode[]; - readonly baseName?: unknown; - readonly baseType?: unknown; - readonly body?: unknown; - readonly condition?: unknown; - readonly declarations?: readonly AstNode[]; - readonly expression?: unknown; - readonly falseBody?: unknown; - readonly initialValue?: unknown; - readonly kind?: string; - readonly leftExpression?: unknown; - readonly length?: unknown; - readonly memberName?: string; - readonly members?: readonly AstNode[]; - readonly namePath?: string; - readonly nodes?: readonly AstNode[]; - readonly operator?: string; - readonly referencedDeclaration?: number; - readonly rightExpression?: unknown; - readonly statements?: readonly AstNode[]; - readonly trueBody?: unknown; - readonly typeDescriptions?: unknown; - readonly typeString?: string; - readonly typeName?: unknown; - readonly value?: string; -}; - -type StorageLayout = StorageConfig; -type StorageType = StorageLayout["types"][string]; - -export type TypewriterSolidityEntrypoint = string & { - readonly __typewriterSolidityEntrypoint?: metadata; -}; - -type SolidityArtifact = { - readonly abi?: unknown; - readonly ast?: AstNode; - readonly bytecode?: { readonly object?: unknown }; - readonly storageLayout?: StorageLayout; -}; - -type FoundryProject = { - readonly root: string; +/** The parts of solc's standard JSON output that typewriter reads. */ +export type SolcOutput = { + sources: { [path: string]: { ast: AstNode } }; + contracts: { + [path: string]: { + [name: string]: { + abi: Abi; + storageLayout: StorageConfig; + evm: { bytecode: { object: string } }; + }; + }; + }; }; -type ParsedMutation = { - readonly enumName: string; - readonly id: number; - readonly params: readonly AbiParameter[]; +/** A solc AST node. Only the fields of its `nodeType` are set. */ +type AstNode = { + id: number; + nodeType: string; + name: string; + abstract: boolean; + contractKind: string; + linearizedBaseContracts: number[]; + nodes: AstNode[]; + members: AstNode[]; + implemented: boolean; + parameters: { parameters: AstNode[] }; + body: AstNode; + condition: AstNode; + trueBody: AstNode; + operator: string; + leftExpression: AstNode; + rightExpression: AstNode; + kind: string; + expression: AstNode; + arguments: AstNode[]; + memberName: string; + referencedDeclaration: number; + components: AstNode[]; + typeName: AstNode; + baseType: AstNode; + underlyingType: AstNode; + typeDescriptions: { typeString: string }; }; -export type ParsedSolidityMetadata = { - readonly contractName: string; - readonly abi: Abi; +export type SolidityApp = { + contractName: string; + abi: Abi; /** Creation bytecode, without constructor arguments. */ - readonly bytecode: Hex.Hex; - readonly storageLayout: StorageLayout; - readonly mutations: readonly ParsedMutation[]; + bytecode: Hex.Hex; + storageLayout: StorageConfig; + mutations: { [name: string]: ResolvedTypewriterMutationConfig }; }; -function isObject(value: unknown): value is AstNode { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function requireObject(value: unknown, name: string): AstNode { - 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 ConfigError({ message: `Expected ${name} to have a nodeType` }); +/** Reads the typewriter app defined in `path` from the solc output that compiled it. */ +export function parseSolidity(output: SolcOutput, path: string): SolidityApp { + const unit = output.sources[path]?.ast; + if (unit === undefined) { + throw new ConfigError({ message: `${path} was not compiled` }); } - return node; -} - -function asAstNodes(value: unknown): readonly AstNode[] { - if (!Array.isArray(value)) return []; - return value.map((node, index) => asAstNode(node, `AST node ${index}`)); -} - -function displayPath(path: string): string { - const cwd = `${process.cwd()}/`; - return path.startsWith(cwd) ? path.slice(cwd.length) : path; -} - -function dirname(path: string): string { - const index = path.lastIndexOf("/"); - return index === -1 ? "." : path.slice(0, index); -} - -function basename(path: string): string { - const index = path.lastIndexOf("/"); - return index === -1 ? path : path.slice(index + 1); -} - -function joinPath(...parts: readonly string[]): string { - return parts - .filter((part) => part.length > 0) - .join("/") - .replaceAll(/\/+/g, "/"); -} - -function indent(level: number): string { - return " ".repeat(level); -} - -function propertyName(key: string): string { - return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key); -} - -function typeLiteralForValue(value: unknown, level = 0): string { - if (typeof value === "string") return JSON.stringify(value); - if (typeof value === "number") return String(value); - if (typeof value === "boolean") return value ? "true" : "false"; - if (value === null) return "null"; - - if (Array.isArray(value)) { - if (value.length === 0) return "readonly []"; - const items = value - .map( - (item) => - `${indent(level + 1)}${typeLiteralForValue(item, level + 1)},`, - ) - .join("\n"); - return `readonly [\n${items}\n${indent(level)}]`; - } - - if (typeof value === "object") { - const entries = Object.entries(value as Record); - if (entries.length === 0) return "{}"; - const properties = entries - .map( - ([key, child]) => - `${indent(level + 1)}readonly ${propertyName(key)}: ${typeLiteralForValue(child, level + 1)};`, - ) - .join("\n"); - return `{\n${properties}\n${indent(level)}}`; - } - - return "unknown"; -} - -async function fileExists(path: string): Promise { - return await Bun.file(path).exists(); -} - -async function findFoundryProject(entrypoint: string): Promise { - let current = dirname(entrypoint); - while (true) { - const configPath = joinPath(current, "foundry.toml"); - if (await fileExists(configPath)) return { root: current }; - - const next = dirname(current); - if (next === current || current === "") { - throw new ConfigError({ - message: `Could not find foundry.toml for Solidity entrypoint ${displayPath(entrypoint)}`, - }); - } - current = next; - } -} - -/** - * Builds `project` into a new directory, so parallel loads of one app do not - * read each other's partial artifacts. Returns the artifact directory. The - * caller removes `dir`. - */ -async function buildFoundryProject( - project: FoundryProject, - dir: string, -): Promise { - const out = joinPath(dir, "out"); - try { - await $`forge build --ast --extra-output storageLayout --out ${out} --cache-path ${joinPath(dir, "cache")}` - .cwd(project.root) - .quiet(); - } catch (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, - }); + const nodes = new Map(); + for (const source of Object.values(output.sources)) { + for (const node of descendants(source.ast)) nodes.set(node.id, node); } - return out; -} -async function readArtifact(path: string): Promise { - const artifact = await Bun.file(path).json(); - return requireObject(artifact, `artifact ${path}`) as SolidityArtifact; -} + const contract = findAppContract(unit, nodes, path); + const { abi, storageLayout, evm } = output.contracts[path]![contract.name]!; -function requireArtifactAbi(artifact: SolidityArtifact, path: string): Abi { - if (!Array.isArray(artifact.abi)) { + const constructorInputs = + abi.find((item) => item.type === "constructor")?.inputs ?? []; + if (constructorInputs.map((input) => input.type).join() !== "address") { throw new ConfigError({ - message: `Could not find ABI in artifact ${path}`, + message: `${contract.name} must declare \`constructor(address scheduler)\` and pass it to \`Typewriter(scheduler)\`, but its constructor takes (${constructorInputs.map((input) => input.type).join(", ")})`, }); } - return artifact.abi as Abi; -} -function requireArtifactBytecode( - artifact: SolidityArtifact, - path: string, -): Hex.Hex { - const bytecode = artifact.bytecode?.object; - if (typeof bytecode !== "string" || !Hex.validate(bytecode)) { + const state = storageLayout.storage.find((item) => item.label === "state"); + if ( + state === undefined || + storageLayout.types[state.type]?.members === undefined + ) { throw new ConfigError({ - message: `Could not find bytecode in artifact ${path}`, + message: `${contract.name} must keep its state in a struct variable named \`state\``, }); } - return bytecode; -} -/** Typewriter deploys apps itself, so every app takes only the scheduler. */ -function requireStandardConstructor(contractName: string, abi: Abi): void { - const inputs = abi.find((item) => item.type === "constructor")?.inputs ?? []; - if (inputs.length !== 1 || inputs[0]!.type !== "address") { + const bytecode = `0x${evm.bytecode.object}`; + if (!Hex.validate(bytecode, { strict: true })) { throw new ConfigError({ - message: `${contractName} must declare \`constructor(address scheduler)\` and pass it to \`Typewriter(scheduler)\`, but its constructor takes (${inputs.map((input) => input.type).join(", ")})`, + message: `${contract.name} calls an external library function, which typewriter can't execute offchain; make the library's functions internal`, }); } -} - -async function readEntrypointAst( - out: string, - entrypoint: string, -): Promise { - const artifactDir = joinPath(out, basename(entrypoint)); - const glob = new Bun.Glob("*.json"); - for await (const artifactName of glob.scan({ cwd: artifactDir })) { - const artifact = await readArtifact(joinPath(artifactDir, artifactName)); - if (artifact.ast !== undefined) - return asAstNode(artifact.ast, "artifact ast"); - } - throw new ConfigError({ - message: `Could not find compiler AST for ${displayPath(entrypoint)}`, - }); -} - -async function readAllSourceAsts(out: string): Promise { - const glob = new Bun.Glob("**/*.json"); - const asts = new Map(); - for await (const artifactName of glob.scan({ cwd: out })) { - const artifact = await readArtifact(joinPath(out, artifactName)); - if (artifact.ast === undefined) continue; - const ast = asAstNode(artifact.ast, "artifact ast"); - const absolutePath = ast.absolutePath; - if (typeof absolutePath === "string") asts.set(absolutePath, ast); - } - return [...asts.values()]; -} - -async function readReachableSourceAsts( - out: string, - entrypointAst: AstNode, -): Promise { - const allAsts = await readAllSourceAsts(out); - const byPath = new Map( - allAsts.flatMap((ast) => - typeof ast.absolutePath === "string" - ? [[ast.absolutePath, ast] as const] - : [], - ), - ); - const reachable = new Map(); - - const visit = (ast: AstNode) => { - if (typeof ast.absolutePath !== "string") return; - if (reachable.has(ast.absolutePath)) return; - reachable.set(ast.absolutePath, ast); - for (const directive of asAstNodes(ast.nodes).filter( - (node) => node.nodeType === "ImportDirective", - )) { - if (typeof directive.absolutePath !== "string") continue; - const imported = byPath.get(directive.absolutePath); - if (imported !== undefined) visit(imported); - } - }; - - visit(entrypointAst); - return [...reachable.values()]; -} - -function findEntrypointContract(entrypoint: string, ast: AstNode): AstNode { - const contracts = asAstNodes(ast.nodes).filter( - (node) => node.nodeType === "ContractDefinition", + const inherited = contract.linearizedBaseContracts.flatMap( + (id) => nodes.get(id)!.nodes, ); - const typewriterContracts = contracts.filter((contract) => { - const baseContracts = asAstNodes(contract.baseContracts); - return baseContracts.some((base) => { - const baseName = requireObject(base.baseName, "baseName"); - return ( - baseName.name === "Typewriter" || baseName.namePath === "Typewriter" - ); - }); - }); - - if (typewriterContracts.length === 0) { - throw new ConfigError({ - message: `${basename(entrypoint)} does not contain a contract inheriting Typewriter`, - }); - } - if (typewriterContracts.length > 1) { - throw new ConfigError({ - message: `${basename(entrypoint)} contains multiple contracts inheriting Typewriter: ${typewriterContracts - .map((contract) => contract.name) - .join(", ")}`, - }); - } - return typewriterContracts[0]!; -} - -function collectDeclarations(asts: readonly AstNode[]): Map { - const declarations = new Map(); - const visit = (node: AstNode) => { - if (typeof node.id === "number") declarations.set(node.id, node); - for (const child of asAstNodes(node.nodes)) visit(child); - for (const member of asAstNodes(node.members)) visit(member); - }; - for (const ast of asts) visit(ast); - return declarations; -} - -function findContractNode( - contract: AstNode, - nodeType: string, - name: string, -): AstNode { - const matches = asAstNodes(contract.nodes).filter( - (node) => node.nodeType === nodeType && node.name === name, + const mutationEnum = inherited.find( + (node) => node.nodeType === "EnumDefinition" && node.name === "Mutation", ); - if (matches.length !== 1) { + if (mutationEnum === undefined) { throw new ConfigError({ - message: `${contract.name ?? "entrypoint"} must define exactly one ${name} ${nodeType}`, + message: `${contract.name} must declare \`enum Mutation\` with a member for each mutation`, }); } - return matches[0]!; -} - -function abiTypeFromTypeName(typeName: unknown): string { - const node = asAstNode(typeName, "typeName"); - if (node.nodeType === "ElementaryTypeName") { - if (typeof node.name !== "string") + // A contract that isn't abstract implements every function. + const dispatch = inherited.find( + (node) => + node.nodeType === "FunctionDefinition" && + node.name === "dispatch" && + node.implemented, + )!; + const [mutationParam, dataParam] = dispatch.parameters.parameters; + + const branches = new Map(); + for (const node of descendants(dispatch.body)) { + if (node.nodeType !== "IfStatement") continue; + const member = comparedMember(node.condition, mutationParam!.id); + if (!mutationEnum.members.some(({ id }) => id === member)) continue; + if (branches.has(member!)) { throw new ConfigError({ - message: "Expected elementary type to have a name", + message: `${contract.name}.dispatch has more than one branch for Mutation.${nodes.get(member!)!.name}`, }); - return node.name; - } - if (node.nodeType === "UserDefinedTypeName") { - const typeDescriptions = requireObject( - node.typeDescriptions, - "type descriptions", - ); - const typeString = typeDescriptions.typeString; - if (typeof typeString !== "string") - throw new ConfigError({ message: "Expected type to have a typeString" }); - if (typeString.startsWith("enum ")) return "uint8"; - } - if (node.nodeType === "ArrayTypeName") { - const baseType = abiTypeFromTypeName(node.baseType); - const length = - isObject(node.length) && typeof node.length.value === "string" - ? node.length.value - : ""; - return `${baseType}[${length}]`; + } + branches.set(member!, node.trueBody); } - 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") { + const mutations: SolidityApp["mutations"] = {}; + for (const [id, member] of mutationEnum.members.entries()) { + if (Object.hasOwn(BUILTIN_MUTATIONS, member.name)) { throw new ConfigError({ - message: `Expected struct member at ${member.src ?? "unknown source"} to have a name`, + message: `Mutation.${member.name} is reserved by typewriter; rename it`, }); } - return { - name: member.name, - type: abiTypeFromTypeName(member.typeName), - } satisfies AbiParameter; - }); -} - -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) { + if (id >= FIRST_BUILTIN_MUTATION_ID) { throw new ConfigError({ - message: `Storage layout must contain exactly one top-level ${label} variable`, + message: `Mutation.${member.name} has ID ${id}, but typewriter reserves IDs from ${FIRST_BUILTIN_MUTATION_ID}; Mutation can have at most ${FIRST_BUILTIN_MUTATION_ID} members`, }); } - - const type = layout.types[roots[0]!.type] as StorageType | undefined; - if ( - label === "accounts" && - (type === undefined || - typeof type.key !== "string" || - typeof type.value !== "string") - ) { + const branch = branches.get(member.id); + if (branch === undefined) { throw new ConfigError({ - message: "Top-level accounts storage variable must be a mapping", + message: `${contract.name}.dispatch has no branch for Mutation.${member.name}; add \`if (mutation == uint8(Mutation.${member.name}))\``, }); } - if ( - label === "state" && - (type === undefined || !Array.isArray(type.members)) - ) { + const struct = decodedStruct(branch, dataParam!.id, nodes); + if (struct === undefined) { throw new ConfigError({ - message: "Top-level state storage variable must be a struct", + message: `The Mutation.${member.name} branch of ${contract.name}.dispatch must decode mutationData into a struct once, as \`abi.decode(mutationData, (${member.name}))\``, }); } + mutations[member.name] = { id, params: structParameters(struct, nodes) }; + } + for (const [name, { id, params }] of Object.entries(BUILTIN_MUTATIONS)) { + mutations[name] = { id, params }; } - return layout; + return { + contractName: contract.name, + abi, + bytecode, + storageLayout, + mutations, + }; } -function isIdentifier(node: unknown, name: string): boolean { - return isObject(node) && node.nodeType === "Identifier" && node.name === name; +function* descendants(value: unknown): Generator { + if (typeof value !== "object" || value === null) return; + if ("nodeType" in value) yield value as AstNode; + for (const child of Object.values(value)) yield* descendants(child); } -function mutationNameFromCondition(condition: AstNode): string | undefined { - if (condition.nodeType !== "BinaryOperation" || condition.operator !== "==") { - return undefined; - } - - const left = condition.leftExpression; - const right = condition.rightExpression; - return ( - mutationNameFromComparison(left, right) ?? - mutationNameFromComparison(right, left) +function findAppContract( + unit: AstNode, + nodes: Map, + path: string, +): AstNode { + const contracts = unit.nodes.filter( + (node) => + node.nodeType === "ContractDefinition" && + node.contractKind === "contract" && + !node.abstract && + node.linearizedBaseContracts.some( + (id) => nodes.get(id)!.name === "Typewriter", + ), ); -} - -function mutationNameFromComparison( - mutationExpression: unknown, - enumExpression: unknown, -): string | undefined { - if (isMutationCast(mutationExpression) && isMutationMember(enumExpression)) { - return (enumExpression as AstNode).memberName as string; - } - if ( - isIdentifier(mutationExpression, "mutation") && - isUint8MutationMember(enumExpression) - ) { - const args = asAstNodes((enumExpression as AstNode).arguments); - return (args[0] as AstNode).memberName as string; + if (contracts.length !== 1) { + throw new ConfigError({ + message: `${path} must define one contract that inherits Typewriter and isn't abstract, but defines ${contracts.length === 0 ? "none" : contracts.map(({ name }) => name).join(", ")}`, + }); } - return undefined; + return contracts[0]!; } -function isMutationCast(node: unknown): boolean { - if (!isObject(node) || node.nodeType !== "FunctionCall") return false; - if (node.kind !== "typeConversion") return false; - if (!isIdentifier(node.expression, "Mutation")) return false; - const args = asAstNodes(node.arguments); - return args.length === 1 && isIdentifier(args[0], "mutation"); +/** `T(x)` → `x`, for any number of type conversions. */ +function unwrap(node: AstNode): AstNode { + return node.nodeType === "FunctionCall" && node.kind === "typeConversion" + ? unwrap(node.arguments[0]!) + : node; } -function isMutationMember(node: unknown): boolean { +function references(node: AstNode, declaration: number): boolean { return ( - isObject(node) && - node.nodeType === "MemberAccess" && - isIdentifier(node.expression, "Mutation") && - typeof node.memberName === "string" + node.nodeType === "Identifier" && node.referencedDeclaration === declaration ); } -function isUint8MutationMember(node: unknown): boolean { - if (!isObject(node) || node.nodeType !== "FunctionCall") return false; - if (node.kind !== "typeConversion") return false; - if (!isUint8TypeExpression(node.expression)) return false; - const args = asAstNodes(node.arguments); - return args.length === 1 && isMutationMember(args[0]); -} - -function isUint8TypeExpression(node: unknown): boolean { - if (isIdentifier(node, "uint8")) return true; - if (!isObject(node) || node.nodeType !== "ElementaryTypeNameExpression") { - return false; - } - const typeName = node.typeName; - return isObject(typeName) && typeName.name === "uint8"; -} - -function flattenDispatchBranches(statement: AstNode): readonly { - readonly name: string; - readonly body: AstNode; -}[] { - if (statement.nodeType !== "IfStatement") return []; - const name = mutationNameFromCondition( - asAstNode(statement.condition, "if condition"), - ); - const branches = - name === undefined - ? [] - : [{ name, body: asAstNode(statement.trueBody, "if trueBody") }]; - const falseBody = statement.falseBody; - if (isObject(falseBody) && falseBody.nodeType === "IfStatement") { - return [...branches, ...flattenDispatchBranches(falseBody as AstNode)]; +/** The `Mutation` member that `mutation == uint8(Mutation.X)` compares with. */ +function comparedMember( + condition: AstNode, + mutationParam: number, +): number | undefined { + if (condition.nodeType !== "BinaryOperation" || condition.operator !== "==") { + return undefined; } - return branches; -} - -function isAbiDecodeOf(initialValue: unknown, dataName: string): boolean { - if (!isObject(initialValue) || initialValue.nodeType !== "FunctionCall") - return false; - const expression = initialValue.expression; - if (!isObject(expression) || expression.nodeType !== "MemberAccess") - return false; - if (expression.memberName !== "decode") return false; - if (!isIdentifier(expression.expression, "abi")) return false; - const args = asAstNodes(initialValue.arguments); - return args.length >= 1 && isIdentifier(args[0], dataName); -} - -function declarationStructId(statement: AstNode): number | undefined { - const declarations = asAstNodes(statement.declarations); - if (declarations.length !== 1) return undefined; - const declaration = declarations[0]!; - const typeName = declaration.typeName; - if (!isObject(typeName)) return undefined; - return typeof typeName.referencedDeclaration === "number" - ? typeName.referencedDeclaration + const left = unwrap(condition.leftExpression); + const right = unwrap(condition.rightExpression); + const other = references(left, mutationParam) + ? right + : references(right, mutationParam) + ? left + : undefined; + return other?.nodeType === "MemberAccess" + ? other.referencedDeclaration : undefined; } -function findDecodeStructIds( - body: AstNode, - dataName: string, -): readonly number[] { - return asAstNodes(body.statements).flatMap((statement) => { - if (statement.nodeType !== "VariableDeclarationStatement") return []; - if (!isAbiDecodeOf(statement.initialValue, dataName)) return []; - const id = declarationStructId(statement); - return id === undefined ? [] : [id]; - }); -} - -function parseMutations(params: { - readonly contract: AstNode; - readonly declarations: Map; -}): readonly ParsedMutation[] { - const mutationEnum = findContractNode( - params.contract, - "EnumDefinition", - "Mutation", - ); - const enumMembers = asAstNodes(mutationEnum.members).map((member, id) => { - if (typeof member.name !== "string") { - 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 ConfigError({ - message: `Mutation.${member.name} is reserved by Typewriter`, - }); - } - if (id >= 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 }; - }); - const dispatch = findContractNode( - params.contract, - "FunctionDefinition", - "dispatch", +/** The struct of the one `abi.decode(mutationData, (Struct))` in `branch`. */ +function decodedStruct( + branch: AstNode, + dataParam: number, + nodes: Map, +): AstNode | undefined { + const decodes = [...descendants(branch)].filter( + (node) => + node.nodeType === "FunctionCall" && + node.expression.nodeType === "MemberAccess" && + node.expression.memberName === "decode" && + node.expression.expression.name === "abi" && + references(node.arguments[0]!, dataParam), ); - const body = asAstNode(dispatch.body, "dispatch body"); - const firstStatement = asAstNodes(body.statements)[0]; - if (firstStatement === undefined) - throw new ConfigError({ - message: "Expected dispatch to contain mutation branches", - }); - const branches = new Map( - flattenDispatchBranches(firstStatement).map((branch) => [ - branch.name, - branch.body, - ]), + if (decodes.length !== 1) return undefined; + const types = decodes[0]!.arguments[1]!; + const type = + types.nodeType === "TupleExpression" && types.components.length === 1 + ? types.components[0]! + : types; + const struct = nodes.get(type.referencedDeclaration); + return struct?.nodeType === "StructDefinition" ? struct : undefined; +} + +function structParameters( + struct: AstNode, + nodes: Map, +): AbiParameter[] { + return struct.members.map((member) => + abiParameter(member.name, member.typeName, nodes, struct.name), ); - - return enumMembers.map((member) => { - const branch = branches.get(member.enumName); - if (branch === undefined) { - throw new ConfigError({ - message: `Could not find dispatch branch for Mutation.${member.enumName}`, - }); - } - const mutationStructIds = findDecodeStructIds(branch, "mutationData"); - if (mutationStructIds.length !== 1) { - 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 ConfigError({ - message: `Mutation.${member.enumName} decode target is not a struct`, - }); - } - return { - enumName: member.enumName, - id: member.id, - params: structToAbiParameters(mutationStruct), - }; - }); } -export function createSolidityTypewriterApp< - sequencingConfig extends SequencingConfig, ->( - baseConfig: TypewriterConfig, - metadata: ParsedSolidityMetadata, - devChain?: DevChain, -): TypewriterApp { - const mutations: Record = {}; - - for (const mutation of metadata.mutations) { - mutations[mutation.enumName] = { - id: mutation.id, - params: mutation.params, - }; +function abiParameter( + name: string, + typeName: AstNode, + nodes: Map, + structName: string, +): AbiParameter { + const { typeString } = typeName.typeDescriptions; + if (typeName.nodeType === "ElementaryTypeName") { + return { name, type: typeString.replace(/ payable$/, "") }; } - - return createTypewriterApp({ - config: baseConfig, - abi: metadata.abi, - storageLayout: metadata.storageLayout, - mutations, - devChain, + if (typeName.nodeType === "ArrayTypeName") { + const item = abiParameter(name, typeName.baseType, nodes, structName); + // The type string has the length of `T[N]` with `N` a constant. + return { ...item, type: `${item.type}${/\[\d*\]$/.exec(typeString)![0]}` }; + } + const declaration = nodes.get(typeName.referencedDeclaration); + switch (declaration?.nodeType) { + case "EnumDefinition": + return { name, type: "uint8" }; + case "ContractDefinition": + return { name, type: "address" }; + case "UserDefinedValueTypeDefinition": + return abiParameter(name, declaration.underlyingType, nodes, structName); + case "StructDefinition": + return { + name, + type: "tuple", + components: structParameters(declaration, nodes), + }; + } + throw new ConfigError({ + message: `${structName}.${name} has type ${typeString}, which can't be a mutation param`, }); } -function solidityDeclarationMetadata(metadata: ParsedSolidityMetadata): { - readonly storageLayout: StorageLayout; - readonly mutations: Record< - string, - { readonly id: number; readonly params: readonly AbiParameter[] } - >; -} { - return { - storageLayout: metadata.storageLayout, - mutations: Object.fromEntries( - metadata.mutations.map((mutation) => [ - mutation.enumName, - { id: mutation.id, params: mutation.params }, - ]), - ), - }; -} - -export function formatSolidityDeclaration( - metadata: ParsedSolidityMetadata, -): string { - const metadataLiteral = typeLiteralForValue( - solidityDeclarationMetadata(metadata), - 0, - ); - return `// Generated by typewriter from ${metadata.contractName}.sol. Do not edit by hand. +/** The `.sol.d.ts` that types an app's entrypoint import. */ +export function formatSolidityDeclaration(app: SolidityApp): string { + const metadata = typeLiteral({ + storageLayout: app.storageLayout, + mutations: app.mutations, + }); + return `// Generated by typewriter from ${app.contractName}.sol. Do not edit by hand. -declare const entrypoint: import("typewriter").TypewriterSolidityEntrypoint<${metadataLiteral}>; +declare const entrypoint: import("typewriter").TypewriterSolidityEntrypoint<${metadata}>; export default entrypoint; `; } -async function findPackageRoot(path: string): Promise { - let current = dirname(path); - while (true) { - if (await fileExists(joinPath(current, "package.json"))) return current; - - const next = dirname(current); - if (next === current || current === "") return undefined; - current = next; - } -} - -async function writeIfChanged(path: string, contents: string): Promise { - const existing = Bun.file(path); - if ((await existing.exists()) && (await existing.text()) === contents) return; - await Bun.write(path, contents); -} - -async function maybeWriteAppSolidityDeclaration( - entrypoint: string, - metadata: ParsedSolidityMetadata, -): Promise { - const packageRoot = await findPackageRoot(entrypoint); - if (packageRoot === undefined) return; - - const appDeclaration = joinPath(packageRoot, "src", "typewriter.d.ts"); - if (!(await fileExists(appDeclaration))) return; - - const outputPath = `${entrypoint}.d.ts`; - await writeIfChanged(outputPath, formatSolidityDeclaration(metadata)); -} - -async function requireEntrypointPath( - entrypoint: TypewriterSolidityEntrypoint, -): Promise { - // `entrypoint` is typed as a string, but apps reach this with `as any` casts - // 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 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 ConfigError({ - message: `Typewriter entrypoint file does not exist: ${displayPath(value)}`, - }); - } - return value; -} - -export async function parseSolidityMetadata( - rawEntrypoint: TypewriterSolidityEntrypoint, -): Promise { - const entrypoint = await requireEntrypointPath(rawEntrypoint); - const project = await findFoundryProject(entrypoint); - const dir = await mkdtemp(joinPath(tmpdir(), "typewriter-forge-")); - try { - const out = await buildFoundryProject(project, dir); - return await readSolidityMetadata(out, entrypoint); - } finally { - await rm(dir, { recursive: true, force: true }); +function typeLiteral(value: unknown, level = 0): string { + const indent = " ".repeat(level); + if (Array.isArray(value)) { + if (value.length === 0) return "readonly []"; + const items = value.map( + (item) => `${indent} ${typeLiteral(item, level + 1)},\n`, + ); + return `readonly [\n${items.join("")}${indent}]`; } -} - -async function readSolidityMetadata( - out: string, - entrypoint: string, -): Promise { - const entrypointAst = await readEntrypointAst(out, entrypoint); - const contract = findEntrypointContract(entrypoint, entrypointAst); - if (typeof contract.name !== "string") - throw new ConfigError({ - message: "Expected entrypoint contract to have a name", - }); - - const artifactPath = joinPath( - out, - basename(entrypoint), - `${contract.name}.json`, - ); - const artifact = await readArtifact(artifactPath); - const abi = requireArtifactAbi(artifact, artifactPath); - requireStandardConstructor(contract.name, abi); - if (artifact.storageLayout === undefined) { - throw new ConfigError({ - message: `Could not find storage layout for ${contract.name}; ensure the Foundry profile emits storageLayout`, - }); + if (typeof value === "object" && value !== null) { + const entries = Object.entries(value); + if (entries.length === 0) return "{}"; + const properties = entries.map( + ([key, child]) => + `${indent} readonly ${/^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key)}: ${typeLiteral(child, level + 1)};\n`, + ); + return `{\n${properties.join("")}${indent}}`; } - - const asts = await readReachableSourceAsts(out, entrypointAst); - const declarations = collectDeclarations(asts); - const appMutations = parseMutations({ contract, declarations }); - const builtinMutations = Object.entries(BUILTIN_MUTATIONS).map( - ([enumName, mutation]) => ({ - enumName, - id: mutation.id, - params: mutation.params, - }), - ); - - return { - contractName: contract.name, - abi, - bytecode: requireArtifactBytecode(artifact, artifactPath), - storageLayout: validateRootStorageLayout(artifact.storageLayout), - mutations: [...appMutations, ...builtinMutations], - }; -} - -/** Builds the app's contract, and refreshes its generated declaration. */ -export async function loadSolidityMetadata( - entrypoint: TypewriterSolidityEntrypoint, -): Promise { - const metadata = await parseSolidityMetadata(entrypoint); - const entrypointPath = await requireEntrypointPath(entrypoint); - await maybeWriteAppSolidityDeclaration(entrypointPath, metadata); - return metadata; + return JSON.stringify(value); } diff --git a/packages/typewriter/test/contracts/src/Counter.sol b/packages/typewriter/test/contracts/src/Counter.sol deleted file mode 100644 index 0479f63f..00000000 --- a/packages/typewriter/test/contracts/src/Counter.sol +++ /dev/null @@ -1,37 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.21; - -import {Typewriter, UnknownMutation} from "typewriter/Typewriter.sol"; - -struct State { - uint256 total; -} - -library AddMutation { - struct Add { - uint256 amount; - } - - function execute(State storage state, Add memory add) internal { - state.total += add.amount; - } -} - -contract Counter is Typewriter { - State internal state; - - enum Mutation { - Add - } - - constructor(address scheduler) Typewriter(scheduler) {} - - function dispatch(uint8 mutation, bytes memory mutationData, bytes32) internal override { - if (mutation == uint8(Mutation.Add)) { - AddMutation.Add memory add = abi.decode(mutationData, (AddMutation.Add)); - AddMutation.execute(state, add); - } else { - revert UnknownMutation(mutation); - } - } -} diff --git a/packages/typewriter/test/contracts/src/ExtraConstructorArg.sol b/packages/typewriter/test/contracts/src/ExtraConstructorArg.sol deleted file mode 100644 index 4d887038..00000000 --- a/packages/typewriter/test/contracts/src/ExtraConstructorArg.sol +++ /dev/null @@ -1,11 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.21; - -import {Typewriter} from "typewriter/Typewriter.sol"; - -/// Takes an argument besides the scheduler, so typewriter cannot deploy it. -contract ExtraConstructorArg is Typewriter { - constructor(address scheduler, uint256) Typewriter(scheduler) {} - - function dispatch(uint8, bytes memory, bytes32) internal override {} -} diff --git a/packages/typewriter/test/contracts/src/Harness.sol b/packages/typewriter/test/contracts/src/Harness.sol deleted file mode 100644 index caf26c00..00000000 --- a/packages/typewriter/test/contracts/src/Harness.sol +++ /dev/null @@ -1,65 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.21; - -import {Typewriter, UnknownMutation} from "typewriter/Typewriter.sol"; - -struct State { - mapping(bytes32 => uint256) balances; -} - -library CreditMutation { - struct Credit { - uint256 amount; - } - - function execute(State storage state, Credit memory credit, bytes32 accountID) internal { - state.balances[accountID] += credit.amount; - } -} - -library DebitMutation { - struct Debit { - uint256 amount; - } - - function execute(State storage state, Debit memory debit, bytes32 accountID) internal { - state.balances[accountID] -= debit.amount; - } -} - -library AssertMutation { - struct Assert { - uint256 expected; - } - - function execute(State storage state, Assert memory assertion, bytes32 accountID) internal view { - require(state.balances[accountID] == assertion.expected, "assert failed"); - } -} - -contract Harness is Typewriter { - State internal state; - - enum Mutation { - Credit, - Debit, - Assert - } - - constructor(address scheduler) Typewriter(scheduler) {} - - function dispatch(uint8 mutation, bytes memory mutationData, bytes32 accountID) internal override { - if (mutation == uint8(Mutation.Credit)) { - CreditMutation.Credit memory credit = abi.decode(mutationData, (CreditMutation.Credit)); - CreditMutation.execute(state, credit, accountID); - } else if (mutation == uint8(Mutation.Debit)) { - DebitMutation.Debit memory debit = abi.decode(mutationData, (DebitMutation.Debit)); - DebitMutation.execute(state, debit, accountID); - } else if (mutation == uint8(Mutation.Assert)) { - AssertMutation.Assert memory assertion = abi.decode(mutationData, (AssertMutation.Assert)); - AssertMutation.execute(state, assertion, accountID); - } else { - revert UnknownMutation(mutation); - } - } -} diff --git a/packages/typewriter/test/contracts/src/InvalidEntrypoint.sol b/packages/typewriter/test/contracts/src/InvalidEntrypoint.sol deleted file mode 100644 index 3a95ef04..00000000 --- a/packages/typewriter/test/contracts/src/InvalidEntrypoint.sol +++ /dev/null @@ -1,4 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.21; - -contract InvalidEntrypoint {} diff --git a/packages/typewriter/test/contracts/src/MultipleEntrypoints.sol b/packages/typewriter/test/contracts/src/MultipleEntrypoints.sol deleted file mode 100644 index f2aed54f..00000000 --- a/packages/typewriter/test/contracts/src/MultipleEntrypoints.sol +++ /dev/null @@ -1,8 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.21; - -import {Typewriter} from "typewriter/Typewriter.sol"; - -abstract contract FirstEntrypoint is Typewriter {} - -abstract contract SecondEntrypoint is Typewriter {} diff --git a/packages/typewriter/test/contracts/src/NestedParam.sol b/packages/typewriter/test/contracts/src/NestedParam.sol deleted file mode 100644 index 181b32ed..00000000 --- a/packages/typewriter/test/contracts/src/NestedParam.sol +++ /dev/null @@ -1,45 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.21; - -import {Typewriter, UnknownMutation} from "typewriter/Typewriter.sol"; - -struct Inner { - uint256 a; - uint256 b; -} - -struct State { - uint256 total; -} - -library UpdateMutation { - struct Update { - Inner inner; - } - - function executeUpdate(State storage state, Update memory update) internal { - state.total += update.inner.a + update.inner.b; - } -} - -/// Fixture whose single mutation decodes a struct that itself contains a nested -/// struct member (`Inner`). Exercises the parser's handling of non-elementary -/// mutation params. -contract NestedParam is Typewriter { - State internal state; - - enum Mutation { - Update - } - - constructor(address scheduler) Typewriter(scheduler) {} - - function dispatch(uint8 mutation, bytes memory mutationData, bytes32) internal override { - if (mutation == uint8(Mutation.Update)) { - UpdateMutation.Update memory update = abi.decode(mutationData, (UpdateMutation.Update)); - UpdateMutation.executeUpdate(state, update); - } else { - revert UnknownMutation(mutation); - } - } -} From 20c6b36dcced5ef978efad952378ab084c423f47 Mon Sep 17 00:00:00 2001 From: kyscott18 <43524469+kyscott18@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:05:08 +0000 Subject: [PATCH 02/10] test(typewriter): cover every requirement of a typewriter contract Add the missing cases found by listing what a valid app must satisfy: a scheduler fixed in the inheritance list, a missing state variable, a file-level Mutation enum (now named in the error), a numeric comparison in dispatch, and nested struct params through executeInputSchema. Co-Authored-By: Claude Opus 5.5 --- .../typewriter/src/mutation-schema.test.ts | 37 ++++++++++++++++ packages/typewriter/src/sol-parse.test.ts | 42 ++++++++++++++++++- packages/typewriter/src/sol-parse.ts | 2 +- 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/packages/typewriter/src/mutation-schema.test.ts b/packages/typewriter/src/mutation-schema.test.ts index e5fc9d21..e105c965 100644 --- a/packages/typewriter/src/mutation-schema.test.ts +++ b/packages/typewriter/src/mutation-schema.test.ts @@ -223,3 +223,40 @@ test("rejects an authorization missing a field, or not an object", () => { // The encoder spreads the authorization, so it must be an object. expect(() => validate(TYPED_PARAMS, Object.values(AUTHORIZATION))).toThrow(); }); + +test("accepts nested structs as objects or arrays, and checks their fields", () => { + const leg = [ + { name: "side", type: "uint8" }, + { name: "price", type: "uint128" }, + ]; + const nested: readonly AbiParameters.Parameter[] = [ + { name: "leg", type: "tuple", components: leg }, + { name: "legs", type: "tuple[]", components: leg }, + ]; + const encode = (params: unknown) => { + validate(params, AUTHORIZATION, nested); + return encodeMutationCalldata({ + config: { id: 0, params: nested }, + params, + authorization: AUTHORIZATION, + } as Parameters[0]); + }; + + const canonical = encode({ + leg: { side: 1n, price: 100n }, + legs: [{ side: 0n, price: 5n }], + }); + expect(encode([[1, "100"], [[0, "0x05"]]])).toBe(canonical); + expect(encode({ leg: [1n, 100n], legs: [{ side: 0n, price: 5n }] })).toBe( + canonical, + ); + + for (const [params, message] of [ + [{ leg: { side: 256n, price: 0n }, legs: [] }, "Expected uint8 between"], + [{ leg: { side: 0n }, legs: [] }, "Missing key"], + [{ leg: { side: 0n, price: 0n }, legs: [{ side: 0n }] }, "Missing key"], + [{ leg: { side: 0n, price: 0n }, legs: {} }, "Expected array"], + ] as const) { + expect(() => validate(params, AUTHORIZATION, nested)).toThrow(message); + } +}); diff --git a/packages/typewriter/src/sol-parse.test.ts b/packages/typewriter/src/sol-parse.test.ts index ede3cd1a..d01af770 100644 --- a/packages/typewriter/src/sol-parse.test.ts +++ b/packages/typewriter/src/sol-parse.test.ts @@ -385,13 +385,35 @@ contract App is Typewriter { ); }); -test("parseSolidity errors when state is not a struct", () => { +test("parseSolidity errors when the app fixes the scheduler", () => { + expect(() => + parse(` +contract App is Typewriter(address(1)) { + function dispatch(uint8, bytes memory, bytes32) internal override {} +}`), + ).toThrowErrorMatchingInlineSnapshot( + `"App must declare \`constructor(address scheduler)\` and pass it to \`Typewriter(scheduler)\`, but its constructor takes ()"`, + ); +}); + +test("parseSolidity errors when state is missing or not a struct", () => { expect(() => parse(` contract App is Typewriter { uint256 internal state; constructor(address scheduler) Typewriter(scheduler) {} function dispatch(uint8, bytes memory, bytes32) internal override {} +}`), + ).toThrowErrorMatchingInlineSnapshot( + `"App must keep its state in a struct variable named \`state\`"`, + ); + expect(() => + parse(` +contract App is Typewriter { + struct State { uint256 total; } + State internal store; + constructor(address scheduler) Typewriter(scheduler) {} + function dispatch(uint8, bytes memory, bytes32) internal override {} }`), ).toThrowErrorMatchingInlineSnapshot( `"App must keep its state in a struct variable named \`state\`"`, @@ -426,7 +448,21 @@ test("parseSolidity errors when the app has no Mutation enum", () => { ), ), ).toThrowErrorMatchingInlineSnapshot( - `"App must declare \`enum Mutation\` with a member for each mutation"`, + `"App must declare \`enum Mutation\` inside the contract or a base contract, with a member for each mutation"`, + ); +}); + +test("parseSolidity errors when Mutation is declared outside the contract", () => { + expect(() => + parse(` +enum Mutation { Add } +struct Add { uint256 amount; } +${app(` + function dispatch(uint8 mutation, bytes memory mutationData, bytes32) internal override { + if (mutation == uint8(Mutation.Add)) abi.decode(mutationData, (Add)); + }`)}`), + ).toThrowErrorMatchingInlineSnapshot( + `"App must declare \`enum Mutation\` inside the contract or a base contract, with a member for each mutation"`, ); }); @@ -471,6 +507,8 @@ test("parseSolidity errors when a mutation has no dispatch branch", () => { function dispatch(uint8 mutation, bytes memory mutationData, bytes32) internal override { if (mutation == uint8(Mutation.Add)) abi.decode(mutationData, (Add)); + // Only a comparison with Mutation.Sub counts as its branch. + if (mutation == 1) abi.decode(mutationData, (Add)); }`), ), ).toThrowErrorMatchingInlineSnapshot( diff --git a/packages/typewriter/src/sol-parse.ts b/packages/typewriter/src/sol-parse.ts index 7eefa727..07f6546d 100644 --- a/packages/typewriter/src/sol-parse.ts +++ b/packages/typewriter/src/sol-parse.ts @@ -110,7 +110,7 @@ export function parseSolidity(output: SolcOutput, path: string): SolidityApp { ); if (mutationEnum === undefined) { throw new ConfigError({ - message: `${contract.name} must declare \`enum Mutation\` with a member for each mutation`, + message: `${contract.name} must declare \`enum Mutation\` inside the contract or a base contract, with a member for each mutation`, }); } // A contract that isn't abstract implements every function. From 2e485ca0d4710ab98a0ccf0d68a4d6e66e9665a6 Mon Sep 17 00:00:00 2001 From: kyscott18 <43524469+kyscott18@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:16:13 +0000 Subject: [PATCH 03/10] refactor(typewriter): read mutation params by struct name, not dispatch dispatch is the app's to write, so the parser no longer analyzes it. Mutation.X takes its params from the one struct named X in the entrypoint file or its imports, the convention the README already documents. The app contract must inherit only Typewriter and declare Mutation inside itself. Co-Authored-By: Claude Opus 5.5 --- README.md | 16 +- packages/typewriter/README.md | 16 +- packages/typewriter/src/sol-parse.test.ts | 373 +++++++--------------- packages/typewriter/src/sol-parse.ts | 158 +++------ 4 files changed, 185 insertions(+), 378 deletions(-) diff --git a/README.md b/README.md index 3e9d6ddc..2767df47 100644 --- a/README.md +++ b/README.md @@ -240,8 +240,8 @@ struct State { #### Mutations Each mutation is a Solidity library with: -- **`struct [Mutation]` definition**. The app's `dispatch` callback ABI-decodes - `mutationData` into this struct. Field names and order define the params +- **`struct [Mutation]` definition**, named after the `Mutation` member. The + app's `dispatch` callback ABI-decodes `mutationData` into this struct. Field names and order define the params extracted into the manifest. Fields can be any ABI type: value types, `bytes`, `string`, arrays, and nested structs. Enums become `uint8`, user-defined value types their underlying type, and contracts and interfaces @@ -484,12 +484,12 @@ through the fixed authorization envelope, consumes the nonce, and calls mutation ID and ABI-decodes the mutation struct. Built-in account mutations are handled entirely by the base contract. -Typewriter reads each mutation's params from `dispatch`: every `Mutation` -member needs exactly one branch that compares the `mutation` argument with it, -as `if (mutation == uint8(Mutation.Add))`, and decodes `mutationData` into a -struct exactly once inside that branch. `dispatch` and `Mutation` can live in -a base contract. Library functions must be `internal`: typewriter executes only -the app contract, so it rejects an app that links external libraries. +Typewriter reads each mutation's params from the struct named after its +`Mutation` member (`Mutation.Add` → `struct Add`), defined in the entrypoint +file or a file it imports; exactly one such struct must exist. The app +contract must inherit only `Typewriter` and declare `enum Mutation` inside +itself. Library functions must be `internal`: typewriter executes only the app +contract, so it rejects an app that links external libraries. #### Determinism diff --git a/packages/typewriter/README.md b/packages/typewriter/README.md index d5e379d0..685739da 100644 --- a/packages/typewriter/README.md +++ b/packages/typewriter/README.md @@ -238,8 +238,8 @@ struct State { #### Mutations Each mutation is a Solidity library with: -- **`struct [Mutation]` definition**. The app's `dispatch` callback ABI-decodes - `mutationData` into this struct. Field names and order define the params +- **`struct [Mutation]` definition**, named after the `Mutation` member. The + app's `dispatch` callback ABI-decodes `mutationData` into this struct. Field names and order define the params extracted into the manifest. Fields can be any ABI type: value types, `bytes`, `string`, arrays, and nested structs. Enums become `uint8`, user-defined value types their underlying type, and contracts and interfaces @@ -482,12 +482,12 @@ through the fixed authorization envelope, consumes the nonce, and calls mutation ID and ABI-decodes the mutation struct. Built-in account mutations are handled entirely by the base contract. -Typewriter reads each mutation's params from `dispatch`: every `Mutation` -member needs exactly one branch that compares the `mutation` argument with it, -as `if (mutation == uint8(Mutation.Add))`, and decodes `mutationData` into a -struct exactly once inside that branch. `dispatch` and `Mutation` can live in -a base contract. Library functions must be `internal`: typewriter executes only -the app contract, so it rejects an app that links external libraries. +Typewriter reads each mutation's params from the struct named after its +`Mutation` member (`Mutation.Add` → `struct Add`), defined in the entrypoint +file or a file it imports; exactly one such struct must exist. The app +contract must inherit only `Typewriter` and declare `enum Mutation` inside +itself. Library functions must be `internal`: typewriter executes only the app +contract, so it rejects an app that links external libraries. #### Determinism diff --git a/packages/typewriter/src/sol-parse.test.ts b/packages/typewriter/src/sol-parse.test.ts index d01af770..a057c945 100644 --- a/packages/typewriter/src/sol-parse.test.ts +++ b/packages/typewriter/src/sol-parse.test.ts @@ -11,18 +11,23 @@ const TYPEWRITER = await Bun.file( new URL("./Typewriter.sol", import.meta.url), ).text(); -/** Compiles `source` as App.sol, next to Typewriter.sol, with solc. */ -function compile(source: string): SolcOutput { +/** + * Compiles `source` as App.sol, next to Typewriter.sol and `files`, with + * solc. + */ +function compile(source: string, files: Record = {}) { + const header = "// SPDX-License-Identifier: MIT\npragma solidity ^0.8.21;\n"; + const sources = Object.fromEntries( + Object.entries({ ...files, "App.sol": source }).map(([path, content]) => [ + path, + { content: `${header}import "Typewriter.sol";\n${content}` }, + ]), + ); const output = JSON.parse( solc.compile( JSON.stringify({ language: "Solidity", - sources: { - "Typewriter.sol": { content: TYPEWRITER }, - "App.sol": { - content: `// SPDX-License-Identifier: MIT\npragma solidity ^0.8.21;\nimport "Typewriter.sol";\n${source}`, - }, - }, + sources: { "Typewriter.sol": { content: TYPEWRITER }, ...sources }, settings: { outputSelection: { "*": { @@ -40,16 +45,16 @@ function compile(source: string): SolcOutput { if (errors?.length) { throw new Error(errors.map((error) => error.formattedMessage).join("\n")); } - return output; + return output as SolcOutput; } -function parse(source: string) { - return parseSolidity(compile(source), "App.sol"); +function parse(source: string, files?: Record) { + return parseSolidity(compile(source, files), "App.sol"); } /** The app's mutations, as `Name(id): type name, ...`. */ -function mutations(source: string): string[] { - return Object.entries(parse(source).mutations) +function mutations(source: string, files?: Record): string[] { + return Object.entries(parse(source, files).mutations) .filter(([name]) => !Object.hasOwn(BUILTIN_MUTATIONS, name)) .map( ([name, { id, params }]) => @@ -57,13 +62,14 @@ function mutations(source: string): string[] { ); } -/** A contract with `state`, the scheduler constructor, and `body`. */ +/** A contract with `state`, the scheduler constructor, `dispatch`, and `body`. */ function app(body: string, name = "App"): string { return ` contract ${name} is Typewriter { struct State { uint256 total; } State internal state; constructor(address scheduler) Typewriter(scheduler) {} + function dispatch(uint8, bytes memory, bytes32) internal override {} ${body} }`; } @@ -202,7 +208,7 @@ struct Leg { Price price; } -struct Order { +struct Place { uint amount; int delta; address payable to; @@ -218,162 +224,121 @@ struct Order { Leg leg; Leg[] legs; } -${app(` - enum Mutation { Place } - - function dispatch(uint8 mutation, bytes memory mutationData, bytes32) internal override { - if (mutation == uint8(Mutation.Place)) abi.decode(mutationData, (Order)); - }`)}`), +${app("enum Mutation { Place }")}`), ).toMatchInlineSnapshot(` [ "Place(0): uint256 amount, int256 delta, address to, bool flag, bytes4 selector, bytes data, string memo, address token, uint8 side, uint128 price, uint256[3] fixedSize, uint8[2][] nested, tuple leg, tuple[] legs", ] `); + const { Place } = parse(` struct Inner { uint8 side; uint128 price; } -struct Outer { Inner leg; Inner[] legs; } -${app(` - enum Mutation { Place } - - function dispatch(uint8 mutation, bytes memory mutationData, bytes32) internal override { - if (mutation == uint8(Mutation.Place)) abi.decode(mutationData, (Outer)); - }`)}`).mutations; +struct Place { Inner leg; Inner[] legs; } +${app("enum Mutation { Place }")}`).mutations; expect(Place).toMatchInlineSnapshot(` - { - "id": 0, - "params": [ - { - "components": [ - { - "name": "side", - "type": "uint8", - }, - { - "name": "price", - "type": "uint128", - }, - ], - "name": "leg", - "type": "tuple", - }, - { - "components": [ - { - "name": "side", - "type": "uint8", - }, - { - "name": "price", - "type": "uint128", - }, - ], - "name": "legs", - "type": "tuple[]", - }, - ], - } - `); -}); - -test("parseSolidity reads the ways dispatch can branch and decode", () => { - expect( - mutations(` -struct A { uint256 a; } -struct B { uint256 b; } -struct C { uint256 c; } -struct D { uint256 d; } - -${app(` - enum Mutation { A, B, C, D } - - function applyC(C memory) internal {} - - function dispatch(uint8 id, bytes memory input, bytes32) internal override { - if (uint8(Mutation.A) == id) { - A memory a = abi.decode(input, (A)); - state.total += a.a; - } - if (Mutation(id) == Mutation.B) { - if (state.total > 0) { - state.total += abi.decode(input, (B)).b; - } - } else if (id == uint8(Mutation.C)) applyC(abi.decode(input, (C))); - else if (id == uint8(Mutation.D)) { - D memory d = abi.decode(input, (D)); - state.total = d.d; - } - }`)}`), - ).toMatchInlineSnapshot(` - [ - "A(0): uint256 a", - "B(1): uint256 b", - "C(2): uint256 c", - "D(3): uint256 d", - ] + { + "id": 0, + "params": [ + { + "components": [ + { + "name": "side", + "type": "uint8", + }, + { + "name": "price", + "type": "uint128", + }, + ], + "name": "leg", + "type": "tuple", + }, + { + "components": [ + { + "name": "side", + "type": "uint8", + }, + { + "name": "price", + "type": "uint128", + }, + ], + "name": "legs", + "type": "tuple[]", + }, + ], + } `); }); -test("parseSolidity reads Mutation and dispatch from a base contract", () => { +test("parseSolidity finds mutation structs in libraries and imported files", () => { expect( - mutations(` -import {Typewriter as Base} from "Typewriter.sol"; - -struct Add { uint256 amount; } + mutations( + ` +import "Mutations.sol"; -abstract contract Counter is Base { - enum Mutation { Add } - - function dispatch(uint8 mutation, bytes memory mutationData, bytes32) internal override { - if (mutation == uint8(Mutation.Add)) abi.decode(mutationData, (Add)); - } +library SubMutation { + struct Sub { int256 amount; } } - -contract Helper {} - -contract App is Counter { - struct State { uint256 total; } - State internal state; - constructor(address scheduler) Base(scheduler) {} -}`), +${app("enum Mutation { Add, Sub }")}`, + { + "Mutations.sol": + "library AddMutation { struct Add { uint256 amount; } }", + // App.sol doesn't import it, so its struct doesn't count. + "Other.sol": "struct Sub { bool unrelated; }", + }, + ), ).toMatchInlineSnapshot(` [ "Add(0): uint256 amount", + "Sub(1): int256 amount", ] `); }); -test("parseSolidity errors when the file defines no app contract", () => { +test("parseSolidity errors when the path was not compiled", () => { expect(() => - parse(` -abstract contract Base is Typewriter {} -contract Other {}`), - ).toThrowErrorMatchingInlineSnapshot( - `"App.sol must define one contract that inherits Typewriter and isn't abstract, but defines none"`, + parseSolidity(compile(COUNTER), "Missing.sol"), + ).toThrowErrorMatchingInlineSnapshot(`"Missing.sol was not compiled"`); +}); + +test("parseSolidity errors when the file defines no app contract", () => { + expect(() => parse("contract Other {}")).toThrowErrorMatchingInlineSnapshot( + `"App.sol must define one contract that inherits Typewriter, but defines none"`, ); }); test("parseSolidity errors when the file defines more than one app contract", () => { - const body = ` - enum Mutation { Add } - struct Add { uint256 amount; } + expect(() => + parse(`${app("", "First")}${app("", "Second")}`), + ).toThrowErrorMatchingInlineSnapshot( + `"App.sol must define one contract that inherits Typewriter, but defines First, Second"`, + ); +}); - function dispatch(uint8 mutation, bytes memory mutationData, bytes32) internal override { - if (mutation == uint8(Mutation.Add)) abi.decode(mutationData, (Add)); - }`; +test("parseSolidity errors when the app inherits anything but Typewriter", () => { expect(() => - parse(`${app(body, "First")}${app(body, "Second")}`), + parse(` +contract Ownable {} +contract App is Typewriter, Ownable { + struct State { uint256 total; } + State internal state; + constructor(address scheduler) Typewriter(scheduler) {} + function dispatch(uint8, bytes memory, bytes32) internal override {} +}`), ).toThrowErrorMatchingInlineSnapshot( - `"App.sol must define one contract that inherits Typewriter and isn't abstract, but defines First, Second"`, + `"App must inherit only Typewriter, but inherits Ownable, Typewriter"`, ); }); -test("parseSolidity errors when the path was not compiled", () => { +test("parseSolidity errors when the app contract is abstract", () => { expect(() => - parseSolidity(compile(COUNTER), "Missing.sol"), - ).toThrowErrorMatchingInlineSnapshot(`"Missing.sol was not compiled"`); + parse(`abstract ${app("enum Mutation { Add } struct Add { uint256 a; }")}`), + ).toThrowErrorMatchingInlineSnapshot(`"App must not be abstract"`); }); -test("parseSolidity errors when the constructor takes more than the scheduler", () => { +test("parseSolidity errors when the constructor doesn't take only the scheduler", () => { expect(() => parse(` contract App is Typewriter { @@ -383,9 +348,6 @@ contract App is Typewriter { ).toThrowErrorMatchingInlineSnapshot( `"App must declare \`constructor(address scheduler)\` and pass it to \`Typewriter(scheduler)\`, but its constructor takes (address, uint256)"`, ); -}); - -test("parseSolidity errors when the app fixes the scheduler", () => { expect(() => parse(` contract App is Typewriter(address(1)) { @@ -430,10 +392,8 @@ ${app(` enum Mutation { Add } struct Add { uint256 amount; } - function dispatch(uint8 mutation, bytes memory mutationData, bytes32) internal override { - if (mutation == uint8(Mutation.Add)) { - state.total += Math.double(abi.decode(mutationData, (Add)).amount); - } + function add(uint256 amount) external { + state.total += Math.double(amount); }`)}`), ).toThrowErrorMatchingInlineSnapshot( `"App calls an external library function, which typewriter can't execute offchain; make the library's functions internal"`, @@ -441,148 +401,59 @@ ${app(` }); test("parseSolidity errors when the app has no Mutation enum", () => { - expect(() => - parse( - app( - "function dispatch(uint8, bytes memory, bytes32) internal override {}", - ), - ), - ).toThrowErrorMatchingInlineSnapshot( - `"App must declare \`enum Mutation\` inside the contract or a base contract, with a member for each mutation"`, + expect(() => parse(app(""))).toThrowErrorMatchingInlineSnapshot( + `"App must declare \`enum Mutation\` inside the contract, with a member for each mutation"`, ); -}); - -test("parseSolidity errors when Mutation is declared outside the contract", () => { expect(() => parse(` enum Mutation { Add } struct Add { uint256 amount; } -${app(` - function dispatch(uint8 mutation, bytes memory mutationData, bytes32) internal override { - if (mutation == uint8(Mutation.Add)) abi.decode(mutationData, (Add)); - }`)}`), +${app("")}`), ).toThrowErrorMatchingInlineSnapshot( - `"App must declare \`enum Mutation\` inside the contract or a base contract, with a member for each mutation"`, + `"App must declare \`enum Mutation\` inside the contract, with a member for each mutation"`, ); }); test("parseSolidity errors when a mutation has a built-in name", () => { expect(() => - parse( - app(` - enum Mutation { CreateAccount } - function dispatch(uint8, bytes memory, bytes32) internal override {}`), - ), + parse(app("enum Mutation { CreateAccount }")), ).toThrowErrorMatchingInlineSnapshot( `"Mutation.CreateAccount is reserved by typewriter; rename it"`, ); }); -test("parseSolidity errors when a mutation has a built-in ID", () => { +test("parseSolidity errors when Mutation has more members than IDs", () => { const members = Array.from({ length: 254 }, (_, i) => `M${i}`); - const branches = members.map( - (member) => - `if (mutation == uint8(Mutation.${member})) abi.decode(mutationData, (Params));`, - ); expect(() => - parse( - app(` - struct Params { uint256 value; } - enum Mutation { ${members.join(", ")} } - function dispatch(uint8 mutation, bytes memory mutationData, bytes32) internal override { - ${branches.join("\n ")} - }`), - ), + parse(app(`enum Mutation { ${members.join(", ")} }`)), ).toThrowErrorMatchingInlineSnapshot( - `"Mutation.M253 has ID 253, but typewriter reserves IDs from 253; Mutation can have at most 253 members"`, + `"Mutation has 254 members, but typewriter reserves IDs from 253; it can have at most 253"`, ); }); -test("parseSolidity errors when a mutation has no dispatch branch", () => { +test("parseSolidity errors when a mutation has no struct, or more than one", () => { expect(() => - parse( - app(` - enum Mutation { Add, Sub } - struct Add { uint256 amount; } - - function dispatch(uint8 mutation, bytes memory mutationData, bytes32) internal override { - if (mutation == uint8(Mutation.Add)) abi.decode(mutationData, (Add)); - // Only a comparison with Mutation.Sub counts as its branch. - if (mutation == 1) abi.decode(mutationData, (Add)); - }`), - ), + parse(app("enum Mutation { Add }")), ).toThrowErrorMatchingInlineSnapshot( - `"App.dispatch has no branch for Mutation.Sub; add \`if (mutation == uint8(Mutation.Sub))\`"`, + `"Mutation.Add takes its params from a struct named Add, but App.sol and its imports define none"`, ); -}); - -test("parseSolidity errors when a mutation has two dispatch branches", () => { expect(() => - parse( - app(` - enum Mutation { Add } - struct Add { uint256 amount; } - - function dispatch(uint8 mutation, bytes memory mutationData, bytes32) internal override { - if (mutation == uint8(Mutation.Add)) abi.decode(mutationData, (Add)); - if (mutation == uint8(Mutation.Add)) abi.decode(mutationData, (Add)); - }`), - ), + parse(` +library AddMutation { struct Add { uint256 amount; } } +library LegacyAddMutation { struct Add { uint128 amount; } } +${app("enum Mutation { Add }")}`), ).toThrowErrorMatchingInlineSnapshot( - `"App.dispatch has more than one branch for Mutation.Add"`, + `"Mutation.Add takes its params from a struct named Add, but App.sol and its imports define AddMutation.Add, LegacyAddMutation.Add"`, ); }); -test("parseSolidity errors when a branch doesn't decode one struct", () => { - const dispatch = (branch: string) => - app(` - enum Mutation { Add } - struct Add { uint256 amount; } - - function dispatch(uint8 mutation, bytes memory mutationData, bytes32) internal override { - if (mutation == uint8(Mutation.Add)) { - ${branch} - } - }`); - - const error = (branch: string) => { - try { - parse(dispatch(branch)); - } catch (error) { - return (error as Error).message; - } - }; - expect({ - none: error("state.total += 1;"), - twice: error( - "abi.decode(mutationData, (Add)); abi.decode(mutationData, (Add));", - ), - elementary: error("abi.decode(mutationData, (uint256));"), - tuple: error("abi.decode(mutationData, (Add, uint256));"), - otherBytes: error("abi.decode(abi.encode(1), (Add));"), - }).toMatchInlineSnapshot(` - { - "elementary": "The Mutation.Add branch of App.dispatch must decode mutationData into a struct once, as \`abi.decode(mutationData, (Add))\`", - "none": "The Mutation.Add branch of App.dispatch must decode mutationData into a struct once, as \`abi.decode(mutationData, (Add))\`", - "otherBytes": "The Mutation.Add branch of App.dispatch must decode mutationData into a struct once, as \`abi.decode(mutationData, (Add))\`", - "tuple": "The Mutation.Add branch of App.dispatch must decode mutationData into a struct once, as \`abi.decode(mutationData, (Add))\`", - "twice": "The Mutation.Add branch of App.dispatch must decode mutationData into a struct once, as \`abi.decode(mutationData, (Add))\`", - } - `); -}); - test("parseSolidity errors when a param has no ABI type", () => { expect(() => parse(` -struct Callback { function() external fn; } -${app(` - enum Mutation { Call } - - function dispatch(uint8 mutation, bytes memory mutationData, bytes32) internal override { - if (mutation == uint8(Mutation.Call)) abi.decode(mutationData, (Callback)); - }`)}`), +struct Call { function() external fn; } +${app("enum Mutation { Call }")}`), ).toThrowErrorMatchingInlineSnapshot( - `"Callback.fn has type function () external, which can't be a mutation param"`, + `"Call.fn has type function () external, which can't be a mutation param"`, ); }); diff --git a/packages/typewriter/src/sol-parse.ts b/packages/typewriter/src/sol-parse.ts index 07f6546d..4f498746 100644 --- a/packages/typewriter/src/sol-parse.ts +++ b/packages/typewriter/src/sol-parse.ts @@ -29,25 +29,13 @@ type AstNode = { id: number; nodeType: string; name: string; + canonicalName: string; abstract: boolean; - contractKind: string; linearizedBaseContracts: number[]; nodes: AstNode[]; members: AstNode[]; - implemented: boolean; - parameters: { parameters: AstNode[] }; - body: AstNode; - condition: AstNode; - trueBody: AstNode; - operator: string; - leftExpression: AstNode; - rightExpression: AstNode; - kind: string; - expression: AstNode; - arguments: AstNode[]; - memberName: string; + sourceUnit: number; referencedDeclaration: number; - components: AstNode[]; typeName: AstNode; baseType: AstNode; underlyingType: AstNode; @@ -102,39 +90,21 @@ export function parseSolidity(output: SolcOutput, path: string): SolidityApp { }); } - const inherited = contract.linearizedBaseContracts.flatMap( - (id) => nodes.get(id)!.nodes, - ); - const mutationEnum = inherited.find( + const mutationEnum = contract.nodes.find( (node) => node.nodeType === "EnumDefinition" && node.name === "Mutation", ); if (mutationEnum === undefined) { throw new ConfigError({ - message: `${contract.name} must declare \`enum Mutation\` inside the contract or a base contract, with a member for each mutation`, + message: `${contract.name} must declare \`enum Mutation\` inside the contract, with a member for each mutation`, }); } - // A contract that isn't abstract implements every function. - const dispatch = inherited.find( - (node) => - node.nodeType === "FunctionDefinition" && - node.name === "dispatch" && - node.implemented, - )!; - const [mutationParam, dataParam] = dispatch.parameters.parameters; - - const branches = new Map(); - for (const node of descendants(dispatch.body)) { - if (node.nodeType !== "IfStatement") continue; - const member = comparedMember(node.condition, mutationParam!.id); - if (!mutationEnum.members.some(({ id }) => id === member)) continue; - if (branches.has(member!)) { - throw new ConfigError({ - message: `${contract.name}.dispatch has more than one branch for Mutation.${nodes.get(member!)!.name}`, - }); - } - branches.set(member!, node.trueBody); + if (mutationEnum.members.length > FIRST_BUILTIN_MUTATION_ID) { + throw new ConfigError({ + message: `Mutation has ${mutationEnum.members.length} members, but typewriter reserves IDs from ${FIRST_BUILTIN_MUTATION_ID}; it can have at most ${FIRST_BUILTIN_MUTATION_ID}`, + }); } + const structs = importedStructs(unit, nodes); const mutations: SolidityApp["mutations"] = {}; for (const [id, member] of mutationEnum.members.entries()) { if (Object.hasOwn(BUILTIN_MUTATIONS, member.name)) { @@ -142,24 +112,16 @@ export function parseSolidity(output: SolcOutput, path: string): SolidityApp { message: `Mutation.${member.name} is reserved by typewriter; rename it`, }); } - if (id >= FIRST_BUILTIN_MUTATION_ID) { + const matches = structs.filter(({ name }) => name === member.name); + if (matches.length !== 1) { throw new ConfigError({ - message: `Mutation.${member.name} has ID ${id}, but typewriter reserves IDs from ${FIRST_BUILTIN_MUTATION_ID}; Mutation can have at most ${FIRST_BUILTIN_MUTATION_ID} members`, + message: `Mutation.${member.name} takes its params from a struct named ${member.name}, but ${path} and its imports define ${matches.length === 0 ? "none" : matches.map(({ canonicalName }) => canonicalName).join(", ")}`, }); } - const branch = branches.get(member.id); - if (branch === undefined) { - throw new ConfigError({ - message: `${contract.name}.dispatch has no branch for Mutation.${member.name}; add \`if (mutation == uint8(Mutation.${member.name}))\``, - }); - } - const struct = decodedStruct(branch, dataParam!.id, nodes); - if (struct === undefined) { - throw new ConfigError({ - message: `The Mutation.${member.name} branch of ${contract.name}.dispatch must decode mutationData into a struct once, as \`abi.decode(mutationData, (${member.name}))\``, - }); - } - mutations[member.name] = { id, params: structParameters(struct, nodes) }; + mutations[member.name] = { + id, + params: structParameters(matches[0]!, nodes), + }; } for (const [name, { id, params }] of Object.entries(BUILTIN_MUTATIONS)) { mutations[name] = { id, params }; @@ -188,75 +150,49 @@ function findAppContract( const contracts = unit.nodes.filter( (node) => node.nodeType === "ContractDefinition" && - node.contractKind === "contract" && - !node.abstract && node.linearizedBaseContracts.some( (id) => nodes.get(id)!.name === "Typewriter", ), ); if (contracts.length !== 1) { throw new ConfigError({ - message: `${path} must define one contract that inherits Typewriter and isn't abstract, but defines ${contracts.length === 0 ? "none" : contracts.map(({ name }) => name).join(", ")}`, + message: `${path} must define one contract that inherits Typewriter, but defines ${contracts.length === 0 ? "none" : contracts.map(({ name }) => name).join(", ")}`, }); } - return contracts[0]!; -} - -/** `T(x)` → `x`, for any number of type conversions. */ -function unwrap(node: AstNode): AstNode { - return node.nodeType === "FunctionCall" && node.kind === "typeConversion" - ? unwrap(node.arguments[0]!) - : node; -} - -function references(node: AstNode, declaration: number): boolean { - return ( - node.nodeType === "Identifier" && node.referencedDeclaration === declaration - ); -} - -/** The `Mutation` member that `mutation == uint8(Mutation.X)` compares with. */ -function comparedMember( - condition: AstNode, - mutationParam: number, -): number | undefined { - if (condition.nodeType !== "BinaryOperation" || condition.operator !== "==") { - return undefined; + const contract = contracts[0]!; + const bases = contract.linearizedBaseContracts.slice(1); + if (bases.length !== 1 || nodes.get(bases[0]!)!.name !== "Typewriter") { + throw new ConfigError({ + message: `${contract.name} must inherit only Typewriter, but inherits ${bases.map((id) => nodes.get(id)!.name).join(", ")}`, + }); } - const left = unwrap(condition.leftExpression); - const right = unwrap(condition.rightExpression); - const other = references(left, mutationParam) - ? right - : references(right, mutationParam) - ? left - : undefined; - return other?.nodeType === "MemberAccess" - ? other.referencedDeclaration - : undefined; + if (contract.abstract) { + throw new ConfigError({ + message: `${contract.name} must not be abstract`, + }); + } + return contract; } -/** The struct of the one `abi.decode(mutationData, (Struct))` in `branch`. */ -function decodedStruct( - branch: AstNode, - dataParam: number, +/** The structs defined in `unit` and the files it imports. */ +function importedStructs( + unit: AstNode, nodes: Map, -): AstNode | undefined { - const decodes = [...descendants(branch)].filter( - (node) => - node.nodeType === "FunctionCall" && - node.expression.nodeType === "MemberAccess" && - node.expression.memberName === "decode" && - node.expression.expression.name === "abi" && - references(node.arguments[0]!, dataParam), - ); - if (decodes.length !== 1) return undefined; - const types = decodes[0]!.arguments[1]!; - const type = - types.nodeType === "TupleExpression" && types.components.length === 1 - ? types.components[0]! - : types; - const struct = nodes.get(type.referencedDeclaration); - return struct?.nodeType === "StructDefinition" ? struct : undefined; +): AstNode[] { + const visited = new Set(); + const structs: AstNode[] = []; + const visit = (unit: AstNode) => { + if (visited.has(unit.id)) return; + visited.add(unit.id); + for (const node of descendants(unit)) { + if (node.nodeType === "StructDefinition") structs.push(node); + if (node.nodeType === "ImportDirective") { + visit(nodes.get(node.sourceUnit)!); + } + } + }; + visit(unit); + return structs; } function structParameters( From d59ba830b8209ea3d0d38f42ed316a482c0bdeb6 Mon Sep 17 00:00:00 2001 From: kyscott18 <43524469+kyscott18@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:40:00 +0000 Subject: [PATCH 04/10] refactor(typewriter): require an XMutation library with struct X and execute Each Mutation member X needs exactly one library XMutation, in the entrypoint file or its imports, that declares struct X (the params) and function execute(State storage, X memory, bytes32) internal, where State is the type of the app's state variable. A ...Mutation library without a Mutation member is an error. OrderBook renames its executeX functions to execute and gives AddInstrument's an accountID param. Assessment moves its mutation logic out of dispatch into AMutation.execute and BMutation.execute, unchanged, and its sim storage layout constant takes the new state astId. Co-Authored-By: Claude Opus 5.5 --- README.md | 36 ++-- .../contracts/src/AddInstrument.sol | 2 +- apps/order-book/contracts/src/ChangeOrder.sol | 4 +- apps/order-book/contracts/src/CloseOrder.sol | 2 +- apps/order-book/contracts/src/Deposit.sol | 2 +- apps/order-book/contracts/src/LimitOrder.sol | 2 +- apps/order-book/contracts/src/MarketOrder.sol | 2 +- apps/order-book/contracts/src/OrderBook.sol | 14 +- .../contracts/src/OrderBook.sol.d.ts | 84 ++++---- apps/order-book/contracts/src/Withdrawal.sol | 2 +- .../contracts/test/CloseOrder.t.sol | 2 +- .../contracts/test/LimitOrder.t.sol | 2 +- .../contracts/test/MarketOrder.t.sol | 2 +- packages/typewriter/README.md | 36 ++-- .../simulation-test/assessment-constants.ts | 2 +- .../assessment/src/Assessment.sol | 24 ++- packages/typewriter/src/sol-parse.test.ts | 197 +++++++++++++----- packages/typewriter/src/sol-parse.ts | 81 +++++-- 18 files changed, 315 insertions(+), 181 deletions(-) diff --git a/README.md b/README.md index 2767df47..bad253b8 100644 --- a/README.md +++ b/README.md @@ -239,16 +239,21 @@ struct State { #### Mutations -Each mutation is a Solidity library with: -- **`struct [Mutation]` definition**, named after the `Mutation` member. The - app's `dispatch` callback ABI-decodes `mutationData` into this struct. Field names and order define the params - extracted into the manifest. Fields can be any ABI type: value types, - `bytes`, `string`, arrays, and nested structs. Enums become `uint8`, - user-defined value types their underlying type, and contracts and interfaces - `address`. -- **`execute` function**. Applies the mutation to app state using the - authenticated `accountID`. Account lookup, permissions, expiration, nonce, - and signature checks have already happened in the base contract. +Each `Mutation` member `X` needs exactly one library named `XMutation`, in the +entrypoint file or a file it imports, with: +- **`struct X`**. Typewriter reads the mutation's params from this struct: field + names and order define the params extracted into the manifest. The app's + `dispatch` callback ABI-decodes `mutationData` into it. Fields can be any ABI + type: value types, `bytes`, `string`, arrays, and nested structs. Enums + become `uint8`, user-defined value types their underlying type, and contracts + and interfaces `address`. +- **`function execute(State storage state, X memory mutation, bytes32 accountID) internal`**, + where `State` is the type of the app's `state` variable. Applies the mutation + to app state using the authenticated `accountID`. Account lookup, + permissions, expiration, nonce, and signature checks have already happened in + the base contract. + +A library named `...Mutation` without a matching `Mutation` member is an error. ```solidity library AddMutation { @@ -484,12 +489,11 @@ through the fixed authorization envelope, consumes the nonce, and calls mutation ID and ABI-decodes the mutation struct. Built-in account mutations are handled entirely by the base contract. -Typewriter reads each mutation's params from the struct named after its -`Mutation` member (`Mutation.Add` → `struct Add`), defined in the entrypoint -file or a file it imports; exactly one such struct must exist. The app -contract must inherit only `Typewriter` and declare `enum Mutation` inside -itself. Library functions must be `internal`: typewriter executes only the app -contract, so it rejects an app that links external libraries. +Typewriter doesn't read `dispatch`; it reads each mutation's params from its +`XMutation` library. The app contract must inherit only +`Typewriter` and declare `enum Mutation` inside itself. Library functions must +be `internal`: typewriter executes only the app contract, so it rejects an app +that links external libraries. #### Determinism diff --git a/apps/order-book/contracts/src/AddInstrument.sol b/apps/order-book/contracts/src/AddInstrument.sol index c204598d..ce66f817 100644 --- a/apps/order-book/contracts/src/AddInstrument.sol +++ b/apps/order-book/contracts/src/AddInstrument.sol @@ -12,7 +12,7 @@ library AddInstrumentMutation { uint8 quoteLotExp; } - function executeAddInstrument(State storage state, AddInstrument memory instrument) internal { + function execute(State storage state, AddInstrument memory instrument, bytes32) internal { if (instrument.baseLotExp > 128 || instrument.quoteLotExp > 128) revert LotExpTooLarge(); Instrument storage stored = state.instruments[instrument.instrumentId]; diff --git a/apps/order-book/contracts/src/ChangeOrder.sol b/apps/order-book/contracts/src/ChangeOrder.sol index 8928985d..3a46c288 100644 --- a/apps/order-book/contracts/src/ChangeOrder.sol +++ b/apps/order-book/contracts/src/ChangeOrder.sol @@ -20,7 +20,7 @@ library ChangeOrderMutation { uint64 price; } - function executeChangeOrder(State storage state, ChangeOrder memory changeOrder, bytes32 accountID) internal { + function execute(State storage state, ChangeOrder memory changeOrder, bytes32 accountID) internal { Account storage account = state.accounts[accountID]; Order storage order = account.orders[changeOrder.orderId]; uint64 orderQuantity = order.quantity; @@ -53,7 +53,7 @@ library ChangeOrderMutation { } delete account.orders[changeOrder.orderId]; - LimitOrderMutation.executeLimitOrder( + LimitOrderMutation.execute( state, LimitOrderMutation.LimitOrder({ quantity: uint256(orderQuantity) << instrument.baseLotExp, diff --git a/apps/order-book/contracts/src/CloseOrder.sol b/apps/order-book/contracts/src/CloseOrder.sol index b8f3f525..08f95fa5 100644 --- a/apps/order-book/contracts/src/CloseOrder.sol +++ b/apps/order-book/contracts/src/CloseOrder.sol @@ -8,7 +8,7 @@ library CloseOrderMutation { uint64 orderId; } - function executeCloseOrder(State storage state, CloseOrder memory closeOrder, bytes32 accountID) internal { + function execute(State storage state, CloseOrder memory closeOrder, bytes32 accountID) internal { Account storage account = state.accounts[accountID]; Order storage order = account.orders[closeOrder.orderId]; uint64 orderQuantity = order.quantity; diff --git a/apps/order-book/contracts/src/Deposit.sol b/apps/order-book/contracts/src/Deposit.sol index 5a26a152..81af4fa4 100644 --- a/apps/order-book/contracts/src/Deposit.sol +++ b/apps/order-book/contracts/src/Deposit.sol @@ -9,7 +9,7 @@ library DepositMutation { uint256 amount; } - function executeDeposit(State storage state, Deposit memory deposit, bytes32 accountID) internal { + function execute(State storage state, Deposit memory deposit, bytes32 accountID) internal { state.accounts[accountID].balances[deposit.asset] += deposit.amount; } } diff --git a/apps/order-book/contracts/src/LimitOrder.sol b/apps/order-book/contracts/src/LimitOrder.sol index 64dc5c57..92f6394f 100644 --- a/apps/order-book/contracts/src/LimitOrder.sol +++ b/apps/order-book/contracts/src/LimitOrder.sol @@ -23,7 +23,7 @@ library LimitOrderMutation { uint8 bidOrAsk; } - function executeLimitOrder(State storage state, LimitOrder memory order, bytes32 accountID) internal { + function execute(State storage state, LimitOrder memory order, bytes32 accountID) internal { Instrument storage instrument = state.instruments[order.instrumentId]; address base = instrument.base; if (base == address(0)) revert InvalidInstrument(); diff --git a/apps/order-book/contracts/src/MarketOrder.sol b/apps/order-book/contracts/src/MarketOrder.sol index 2583fc71..2f5499ca 100644 --- a/apps/order-book/contracts/src/MarketOrder.sol +++ b/apps/order-book/contracts/src/MarketOrder.sol @@ -28,7 +28,7 @@ library MarketOrderMutation { uint8 bidOrAsk; } - function executeMarketOrder(State storage state, MarketOrder memory order, bytes32 accountID) internal { + function execute(State storage state, MarketOrder memory order, bytes32 accountID) internal { Instrument storage instrument = state.instruments[order.instrumentId]; if (instrument.base == address(0)) revert InvalidInstrument(); diff --git a/apps/order-book/contracts/src/OrderBook.sol b/apps/order-book/contracts/src/OrderBook.sol index 2c484ead..6205ac4b 100644 --- a/apps/order-book/contracts/src/OrderBook.sol +++ b/apps/order-book/contracts/src/OrderBook.sol @@ -162,27 +162,27 @@ contract OrderBook is Typewriter { function dispatch(uint8 mutation, bytes memory mutationData, bytes32 accountID) internal override { if (mutation == uint8(Mutation.CloseOrder)) { CloseOrderMutation.CloseOrder memory closeOrder = abi.decode(mutationData, (CloseOrderMutation.CloseOrder)); - CloseOrderMutation.executeCloseOrder(state, closeOrder, accountID); + CloseOrderMutation.execute(state, closeOrder, accountID); } else if (mutation == uint8(Mutation.ChangeOrder)) { ChangeOrderMutation.ChangeOrder memory changeOrder = abi.decode(mutationData, (ChangeOrderMutation.ChangeOrder)); - ChangeOrderMutation.executeChangeOrder(state, changeOrder, accountID); + ChangeOrderMutation.execute(state, changeOrder, accountID); } else if (mutation == uint8(Mutation.LimitOrder)) { LimitOrderMutation.LimitOrder memory order = abi.decode(mutationData, (LimitOrderMutation.LimitOrder)); - LimitOrderMutation.executeLimitOrder(state, order, accountID); + LimitOrderMutation.execute(state, order, accountID); } else if (mutation == uint8(Mutation.MarketOrder)) { MarketOrderMutation.MarketOrder memory order = abi.decode(mutationData, (MarketOrderMutation.MarketOrder)); - MarketOrderMutation.executeMarketOrder(state, order, accountID); + MarketOrderMutation.execute(state, order, accountID); } else if (mutation == uint8(Mutation.AddInstrument)) { AddInstrumentMutation.AddInstrument memory instrument = abi.decode(mutationData, (AddInstrumentMutation.AddInstrument)); - AddInstrumentMutation.executeAddInstrument(state, instrument); + AddInstrumentMutation.execute(state, instrument, accountID); } else if (mutation == uint8(Mutation.Deposit)) { DepositMutation.Deposit memory deposit = abi.decode(mutationData, (DepositMutation.Deposit)); - DepositMutation.executeDeposit(state, deposit, accountID); + DepositMutation.execute(state, deposit, accountID); } else if (mutation == uint8(Mutation.Withdrawal)) { WithdrawalMutation.Withdrawal memory withdrawal = abi.decode(mutationData, (WithdrawalMutation.Withdrawal)); - WithdrawalMutation.executeWithdrawal(state, withdrawal, accountID); + WithdrawalMutation.execute(state, withdrawal, accountID); } else { revert UnknownMutation(mutation); } diff --git a/apps/order-book/contracts/src/OrderBook.sol.d.ts b/apps/order-book/contracts/src/OrderBook.sol.d.ts index 670dd0a8..884fe6ad 100644 --- a/apps/order-book/contracts/src/OrderBook.sol.d.ts +++ b/apps/order-book/contracts/src/OrderBook.sol.d.ts @@ -28,12 +28,12 @@ declare const entrypoint: import("typewriter").TypewriterSolidityEntrypoint<{ readonly type: "t_uint256"; }, { - readonly astId: 43944; + readonly astId: 43946; readonly contract: "src/OrderBook.sol:OrderBook"; readonly label: "state"; readonly offset: 0; readonly slot: "3"; - readonly type: "t_struct(State)43461_storage"; + readonly type: "t_struct(State)43463_storage"; }, ]; readonly types: { @@ -48,11 +48,11 @@ declare const entrypoint: import("typewriter").TypewriterSolidityEntrypoint<{ readonly numberOfBytes: "32"; readonly base: "t_struct(Credential)15_storage"; }; - readonly "t_array(t_struct(Order)43481_storage)dyn_storage": { + readonly "t_array(t_struct(Order)43483_storage)dyn_storage": { readonly encoding: "dynamic_array"; readonly label: "struct Order[]"; readonly numberOfBytes: "32"; - readonly base: "t_struct(Order)43481_storage"; + readonly base: "t_struct(Order)43483_storage"; }; readonly "t_array(t_struct(QueuedMutation)657_storage)dyn_storage": { readonly encoding: "dynamic_array"; @@ -89,12 +89,12 @@ declare const entrypoint: import("typewriter").TypewriterSolidityEntrypoint<{ readonly numberOfBytes: "32"; readonly value: "t_struct(Account)26_storage"; }; - readonly "t_mapping(t_bytes32,t_struct(Account)43470_storage)": { + readonly "t_mapping(t_bytes32,t_struct(Account)43472_storage)": { readonly encoding: "mapping"; readonly key: "t_bytes32"; readonly label: "mapping(bytes32 => struct Account)"; readonly numberOfBytes: "32"; - readonly value: "t_struct(Account)43470_storage"; + readonly value: "t_struct(Account)43472_storage"; }; readonly "t_mapping(t_uint192,t_uint64)": { readonly encoding: "mapping"; @@ -103,19 +103,19 @@ declare const entrypoint: import("typewriter").TypewriterSolidityEntrypoint<{ readonly numberOfBytes: "32"; readonly value: "t_uint64"; }; - readonly "t_mapping(t_uint64,t_struct(Instrument)43504_storage)": { + readonly "t_mapping(t_uint64,t_struct(Instrument)43506_storage)": { readonly encoding: "mapping"; readonly key: "t_uint64"; readonly label: "mapping(uint64 => struct Instrument)"; readonly numberOfBytes: "32"; - readonly value: "t_struct(Instrument)43504_storage"; + readonly value: "t_struct(Instrument)43506_storage"; }; - readonly "t_mapping(t_uint64,t_struct(Tick)43515_storage)": { + readonly "t_mapping(t_uint64,t_struct(Tick)43517_storage)": { readonly encoding: "mapping"; readonly key: "t_uint64"; readonly label: "mapping(uint64 => struct Tick)"; readonly numberOfBytes: "32"; - readonly value: "t_struct(Tick)43515_storage"; + readonly value: "t_struct(Tick)43517_storage"; }; readonly "t_struct(Account)26_storage": { readonly encoding: "inplace"; @@ -148,13 +148,13 @@ declare const entrypoint: import("typewriter").TypewriterSolidityEntrypoint<{ }, ]; }; - readonly "t_struct(Account)43470_storage": { + readonly "t_struct(Account)43472_storage": { readonly encoding: "inplace"; readonly label: "struct Account"; readonly numberOfBytes: "64"; readonly members: readonly [ { - readonly astId: 43465; + readonly astId: 43467; readonly contract: "src/OrderBook.sol:OrderBook"; readonly label: "balances"; readonly offset: 0; @@ -162,12 +162,12 @@ declare const entrypoint: import("typewriter").TypewriterSolidityEntrypoint<{ readonly type: "t_mapping(t_address,t_uint256)"; }, { - readonly astId: 43469; + readonly astId: 43471; readonly contract: "src/OrderBook.sol:OrderBook"; readonly label: "orders"; readonly offset: 0; readonly slot: "1"; - readonly type: "t_array(t_struct(Order)43481_storage)dyn_storage"; + readonly type: "t_array(t_struct(Order)43483_storage)dyn_storage"; }, ]; }; @@ -210,13 +210,13 @@ declare const entrypoint: import("typewriter").TypewriterSolidityEntrypoint<{ }, ]; }; - readonly "t_struct(Instrument)43504_storage": { + readonly "t_struct(Instrument)43506_storage": { readonly encoding: "inplace"; readonly label: "struct Instrument"; readonly numberOfBytes: "160"; readonly members: readonly [ { - readonly astId: 43483; + readonly astId: 43485; readonly contract: "src/OrderBook.sol:OrderBook"; readonly label: "base"; readonly offset: 0; @@ -224,7 +224,7 @@ declare const entrypoint: import("typewriter").TypewriterSolidityEntrypoint<{ readonly type: "t_address"; }, { - readonly astId: 43485; + readonly astId: 43487; readonly contract: "src/OrderBook.sol:OrderBook"; readonly label: "quote"; readonly offset: 0; @@ -232,7 +232,7 @@ declare const entrypoint: import("typewriter").TypewriterSolidityEntrypoint<{ readonly type: "t_address"; }, { - readonly astId: 43487; + readonly astId: 43489; readonly contract: "src/OrderBook.sol:OrderBook"; readonly label: "baseLotExp"; readonly offset: 20; @@ -240,7 +240,7 @@ declare const entrypoint: import("typewriter").TypewriterSolidityEntrypoint<{ readonly type: "t_uint8"; }, { - readonly astId: 43489; + readonly astId: 43491; readonly contract: "src/OrderBook.sol:OrderBook"; readonly label: "quoteLotExp"; readonly offset: 21; @@ -248,7 +248,7 @@ declare const entrypoint: import("typewriter").TypewriterSolidityEntrypoint<{ readonly type: "t_uint8"; }, { - readonly astId: 43491; + readonly astId: 43493; readonly contract: "src/OrderBook.sol:OrderBook"; readonly label: "bestBid"; readonly offset: 22; @@ -256,7 +256,7 @@ declare const entrypoint: import("typewriter").TypewriterSolidityEntrypoint<{ readonly type: "t_uint64"; }, { - readonly astId: 43493; + readonly astId: 43495; readonly contract: "src/OrderBook.sol:OrderBook"; readonly label: "bestAsk"; readonly offset: 0; @@ -264,30 +264,30 @@ declare const entrypoint: import("typewriter").TypewriterSolidityEntrypoint<{ readonly type: "t_uint64"; }, { - readonly astId: 43498; + readonly astId: 43500; readonly contract: "src/OrderBook.sol:OrderBook"; readonly label: "bids"; readonly offset: 0; readonly slot: "3"; - readonly type: "t_mapping(t_uint64,t_struct(Tick)43515_storage)"; + readonly type: "t_mapping(t_uint64,t_struct(Tick)43517_storage)"; }, { - readonly astId: 43503; + readonly astId: 43505; readonly contract: "src/OrderBook.sol:OrderBook"; readonly label: "asks"; readonly offset: 0; readonly slot: "4"; - readonly type: "t_mapping(t_uint64,t_struct(Tick)43515_storage)"; + readonly type: "t_mapping(t_uint64,t_struct(Tick)43517_storage)"; }, ]; }; - readonly "t_struct(Order)43481_storage": { + readonly "t_struct(Order)43483_storage": { readonly encoding: "inplace"; readonly label: "struct Order"; readonly numberOfBytes: "32"; readonly members: readonly [ { - readonly astId: 43472; + readonly astId: 43474; readonly contract: "src/OrderBook.sol:OrderBook"; readonly label: "quantity"; readonly offset: 0; @@ -295,7 +295,7 @@ declare const entrypoint: import("typewriter").TypewriterSolidityEntrypoint<{ readonly type: "t_uint64"; }, { - readonly astId: 43474; + readonly astId: 43476; readonly contract: "src/OrderBook.sol:OrderBook"; readonly label: "instrumentId"; readonly offset: 8; @@ -303,7 +303,7 @@ declare const entrypoint: import("typewriter").TypewriterSolidityEntrypoint<{ readonly type: "t_uint64"; }, { - readonly astId: 43476; + readonly astId: 43478; readonly contract: "src/OrderBook.sol:OrderBook"; readonly label: "price"; readonly offset: 16; @@ -311,7 +311,7 @@ declare const entrypoint: import("typewriter").TypewriterSolidityEntrypoint<{ readonly type: "t_uint64"; }, { - readonly astId: 43478; + readonly astId: 43480; readonly contract: "src/OrderBook.sol:OrderBook"; readonly label: "tickVolume"; readonly offset: 24; @@ -319,7 +319,7 @@ declare const entrypoint: import("typewriter").TypewriterSolidityEntrypoint<{ readonly type: "t_uint32"; }, { - readonly astId: 43480; + readonly astId: 43482; readonly contract: "src/OrderBook.sol:OrderBook"; readonly label: "side"; readonly offset: 28; @@ -367,36 +367,36 @@ declare const entrypoint: import("typewriter").TypewriterSolidityEntrypoint<{ }, ]; }; - readonly "t_struct(State)43461_storage": { + readonly "t_struct(State)43463_storage": { readonly encoding: "inplace"; readonly label: "struct State"; readonly numberOfBytes: "64"; readonly members: readonly [ { - readonly astId: 43455; + readonly astId: 43457; readonly contract: "src/OrderBook.sol:OrderBook"; readonly label: "accounts"; readonly offset: 0; readonly slot: "0"; - readonly type: "t_mapping(t_bytes32,t_struct(Account)43470_storage)"; + readonly type: "t_mapping(t_bytes32,t_struct(Account)43472_storage)"; }, { - readonly astId: 43460; + readonly astId: 43462; readonly contract: "src/OrderBook.sol:OrderBook"; readonly label: "instruments"; readonly offset: 0; readonly slot: "1"; - readonly type: "t_mapping(t_uint64,t_struct(Instrument)43504_storage)"; + readonly type: "t_mapping(t_uint64,t_struct(Instrument)43506_storage)"; }, ]; }; - readonly "t_struct(Tick)43515_storage": { + readonly "t_struct(Tick)43517_storage": { readonly encoding: "inplace"; readonly label: "struct Tick"; readonly numberOfBytes: "64"; readonly members: readonly [ { - readonly astId: 43506; + readonly astId: 43508; readonly contract: "src/OrderBook.sol:OrderBook"; readonly label: "quantity"; readonly offset: 0; @@ -404,7 +404,7 @@ declare const entrypoint: import("typewriter").TypewriterSolidityEntrypoint<{ readonly type: "t_uint64"; }, { - readonly astId: 43508; + readonly astId: 43510; readonly contract: "src/OrderBook.sol:OrderBook"; readonly label: "remainingQuantity"; readonly offset: 8; @@ -412,7 +412,7 @@ declare const entrypoint: import("typewriter").TypewriterSolidityEntrypoint<{ readonly type: "t_uint64"; }, { - readonly astId: 43510; + readonly astId: 43512; readonly contract: "src/OrderBook.sol:OrderBook"; readonly label: "volume"; readonly offset: 16; @@ -420,7 +420,7 @@ declare const entrypoint: import("typewriter").TypewriterSolidityEntrypoint<{ readonly type: "t_uint32"; }, { - readonly astId: 43512; + readonly astId: 43514; readonly contract: "src/OrderBook.sol:OrderBook"; readonly label: "prev"; readonly offset: 20; @@ -428,7 +428,7 @@ declare const entrypoint: import("typewriter").TypewriterSolidityEntrypoint<{ readonly type: "t_uint64"; }, { - readonly astId: 43514; + readonly astId: 43516; readonly contract: "src/OrderBook.sol:OrderBook"; readonly label: "next"; readonly offset: 0; diff --git a/apps/order-book/contracts/src/Withdrawal.sol b/apps/order-book/contracts/src/Withdrawal.sol index 64938f64..5fa365b4 100644 --- a/apps/order-book/contracts/src/Withdrawal.sol +++ b/apps/order-book/contracts/src/Withdrawal.sol @@ -9,7 +9,7 @@ library WithdrawalMutation { uint256 amount; } - function executeWithdrawal(State storage state, Withdrawal memory withdrawal, bytes32 accountID) internal { + function execute(State storage state, Withdrawal memory withdrawal, bytes32 accountID) internal { if (state.accounts[accountID].balances[withdrawal.asset] < withdrawal.amount) { revert InsufficientBalance(); } diff --git a/apps/order-book/contracts/test/CloseOrder.t.sol b/apps/order-book/contracts/test/CloseOrder.t.sol index 97a0af87..4c0487e1 100644 --- a/apps/order-book/contracts/test/CloseOrder.t.sol +++ b/apps/order-book/contracts/test/CloseOrder.t.sol @@ -25,7 +25,7 @@ contract CloseOrderTest is Test { } function _executeCloseOrder(CloseOrderMutation.CloseOrder memory close, bytes32 account) internal { - CloseOrderMutation.executeCloseOrder(state, close, account); + CloseOrderMutation.execute(state, close, account); } function callCloseOrder(CloseOrderMutation.CloseOrder memory close, bytes32 account) external { diff --git a/apps/order-book/contracts/test/LimitOrder.t.sol b/apps/order-book/contracts/test/LimitOrder.t.sol index ff17421f..ff9f0a65 100644 --- a/apps/order-book/contracts/test/LimitOrder.t.sol +++ b/apps/order-book/contracts/test/LimitOrder.t.sol @@ -31,7 +31,7 @@ contract LimitOrderTest is Test { } function _executeLimitOrder(LimitOrderMutation.LimitOrder memory order, bytes32 account) internal { - LimitOrderMutation.executeLimitOrder(state, order, account); + LimitOrderMutation.execute(state, order, account); } function callLimitOrder(LimitOrderMutation.LimitOrder memory order, bytes32 account) external { diff --git a/apps/order-book/contracts/test/MarketOrder.t.sol b/apps/order-book/contracts/test/MarketOrder.t.sol index 3b7ee804..f6042079 100644 --- a/apps/order-book/contracts/test/MarketOrder.t.sol +++ b/apps/order-book/contracts/test/MarketOrder.t.sol @@ -47,7 +47,7 @@ contract MarketOrderTest is Test { } function _executeMarketOrder(MarketOrderMutation.MarketOrder memory order, bytes32 account) internal { - MarketOrderMutation.executeMarketOrder(state, order, account); + MarketOrderMutation.execute(state, order, account); } function callMarketOrder(MarketOrderMutation.MarketOrder memory order, bytes32 account) external { diff --git a/packages/typewriter/README.md b/packages/typewriter/README.md index 685739da..06f87062 100644 --- a/packages/typewriter/README.md +++ b/packages/typewriter/README.md @@ -237,16 +237,21 @@ struct State { #### Mutations -Each mutation is a Solidity library with: -- **`struct [Mutation]` definition**, named after the `Mutation` member. The - app's `dispatch` callback ABI-decodes `mutationData` into this struct. Field names and order define the params - extracted into the manifest. Fields can be any ABI type: value types, - `bytes`, `string`, arrays, and nested structs. Enums become `uint8`, - user-defined value types their underlying type, and contracts and interfaces - `address`. -- **`execute` function**. Applies the mutation to app state using the - authenticated `accountID`. Account lookup, permissions, expiration, nonce, - and signature checks have already happened in the base contract. +Each `Mutation` member `X` needs exactly one library named `XMutation`, in the +entrypoint file or a file it imports, with: +- **`struct X`**. Typewriter reads the mutation's params from this struct: field + names and order define the params extracted into the manifest. The app's + `dispatch` callback ABI-decodes `mutationData` into it. Fields can be any ABI + type: value types, `bytes`, `string`, arrays, and nested structs. Enums + become `uint8`, user-defined value types their underlying type, and contracts + and interfaces `address`. +- **`function execute(State storage state, X memory mutation, bytes32 accountID) internal`**, + where `State` is the type of the app's `state` variable. Applies the mutation + to app state using the authenticated `accountID`. Account lookup, + permissions, expiration, nonce, and signature checks have already happened in + the base contract. + +A library named `...Mutation` without a matching `Mutation` member is an error. ```solidity library AddMutation { @@ -482,12 +487,11 @@ through the fixed authorization envelope, consumes the nonce, and calls mutation ID and ABI-decodes the mutation struct. Built-in account mutations are handled entirely by the base contract. -Typewriter reads each mutation's params from the struct named after its -`Mutation` member (`Mutation.Add` → `struct Add`), defined in the entrypoint -file or a file it imports; exactly one such struct must exist. The app -contract must inherit only `Typewriter` and declare `enum Mutation` inside -itself. Library functions must be `internal`: typewriter executes only the app -contract, so it rejects an app that links external libraries. +Typewriter doesn't read `dispatch`; it reads each mutation's params from its +`XMutation` library. The app contract must inherit only +`Typewriter` and declare `enum Mutation` inside itself. Library functions must +be `internal`: typewriter executes only the app contract, so it rejects an app +that links external libraries. #### Determinism diff --git a/packages/typewriter/simulation-test/assessment-constants.ts b/packages/typewriter/simulation-test/assessment-constants.ts index af1b2aab..292a68ba 100644 --- a/packages/typewriter/simulation-test/assessment-constants.ts +++ b/packages/typewriter/simulation-test/assessment-constants.ts @@ -27,7 +27,7 @@ export const STORAGE_LAYOUT = { type: "t_uint256", }, { - astId: 1578, + astId: 1685, contract: "src/Assessment.sol:Assessment", label: "state", offset: 0, diff --git a/packages/typewriter/simulation-test/assessment/src/Assessment.sol b/packages/typewriter/simulation-test/assessment/src/Assessment.sol index 81386ce2..732e01a2 100644 --- a/packages/typewriter/simulation-test/assessment/src/Assessment.sol +++ b/packages/typewriter/simulation-test/assessment/src/Assessment.sol @@ -13,12 +13,19 @@ error InvalidScheduler(); error AError(); error BError(); +/// A mutation reverts when `shouldRevert` is set, so a failure depends only on the input. library AMutation { struct A { uint256 slot; uint256 value; bool shouldRevert; } + + function execute(State storage state, A memory a, bytes32 accountID) internal { + if (a.shouldRevert) revert AError(); + state.slots[a.slot] = bytes32(a.value); + state.accumulator = keccak256(abi.encode(state.accumulator, uint8(0), accountID, a.slot, a.value)); + } } library BMutation { @@ -27,6 +34,12 @@ library BMutation { uint256 value; bool shouldRevert; } + + function execute(State storage state, B memory b, bytes32 accountID) internal { + if (b.shouldRevert) revert BError(); + state.slots[b.slot] = keccak256(abi.encode(b.value)); + state.accumulator = keccak256(abi.encode(state.accumulator, uint8(1), accountID, b.slot, b.value)); + } } contract Assessment is Typewriter { @@ -42,18 +55,11 @@ contract Assessment is Typewriter { FORCE_INCLUSION_DELAY = 365 days; } - /// A mutation reverts when `shouldRevert` is set, so a failure depends only on the input. function dispatch(uint8 mutation, bytes memory mutationData, bytes32 accountID) internal override { if (mutation == uint8(Mutation.A)) { - AMutation.A memory a = abi.decode(mutationData, (AMutation.A)); - if (a.shouldRevert) revert AError(); - state.slots[a.slot] = bytes32(a.value); - state.accumulator = keccak256(abi.encode(state.accumulator, uint8(0), accountID, a.slot, a.value)); + AMutation.execute(state, abi.decode(mutationData, (AMutation.A)), accountID); } else if (mutation == uint8(Mutation.B)) { - BMutation.B memory b = abi.decode(mutationData, (BMutation.B)); - if (b.shouldRevert) revert BError(); - state.slots[b.slot] = keccak256(abi.encode(b.value)); - state.accumulator = keccak256(abi.encode(state.accumulator, uint8(1), accountID, b.slot, b.value)); + BMutation.execute(state, abi.decode(mutationData, (BMutation.B)), accountID); } else { revert UnknownMutation(mutation); } diff --git a/packages/typewriter/src/sol-parse.test.ts b/packages/typewriter/src/sol-parse.test.ts index a057c945..3723f900 100644 --- a/packages/typewriter/src/sol-parse.test.ts +++ b/packages/typewriter/src/sol-parse.test.ts @@ -12,22 +12,31 @@ const TYPEWRITER = await Bun.file( ).text(); /** - * Compiles `source` as App.sol, next to Typewriter.sol and `files`, with - * solc. + * Compiles `source` as App.sol with solc, next to Typewriter.sol, State.sol + * (`struct State`), and `files`. Every file imports Typewriter.sol and + * State.sol. */ function compile(source: string, files: Record = {}) { - const header = "// SPDX-License-Identifier: MIT\npragma solidity ^0.8.21;\n"; + const header = + '// SPDX-License-Identifier: MIT\npragma solidity ^0.8.21;\nimport "Typewriter.sol";\nimport "State.sol";\n'; const sources = Object.fromEntries( Object.entries({ ...files, "App.sol": source }).map(([path, content]) => [ path, - { content: `${header}import "Typewriter.sol";\n${content}` }, + { content: `${header}${content}` }, ]), ); const output = JSON.parse( solc.compile( JSON.stringify({ language: "Solidity", - sources: { "Typewriter.sol": { content: TYPEWRITER }, ...sources }, + sources: { + "Typewriter.sol": { content: TYPEWRITER }, + "State.sol": { + content: + "// SPDX-License-Identifier: MIT\npragma solidity ^0.8.21;\nstruct State { uint256 total; }", + }, + ...sources, + }, settings: { outputSelection: { "*": { @@ -62,11 +71,20 @@ function mutations(source: string, files?: Record): string[] { ); } -/** A contract with `state`, the scheduler constructor, `dispatch`, and `body`. */ +/** A mutation library for `name`, whose struct has `fields`. */ +function mutation(name: string, fields = "uint256 amount;"): string { + return ` +library ${name}Mutation { + struct ${name} { ${fields} } + + function execute(State storage state, ${name} memory mutation, bytes32 accountID) internal {} +}`; +} + +/** An app contract with `state`, the scheduler constructor, `dispatch`, and `body`. */ function app(body: string, name = "App"): string { return ` contract ${name} is Typewriter { - struct State { uint256 total; } State internal state; constructor(address scheduler) Typewriter(scheduler) {} function dispatch(uint8, bytes memory, bytes32) internal override {} @@ -75,20 +93,24 @@ contract ${name} is Typewriter { } const COUNTER = ` -struct State { - uint256 total; -} - library AddMutation { struct Add { uint256 amount; } + + function execute(State storage state, Add memory add, bytes32) internal { + state.total += add.amount; + } } library SubMutation { struct Sub { uint256 amount; } + + function execute(State storage state, Sub memory sub, bytes32) internal { + state.total -= sub.amount; + } } contract Counter is Typewriter { @@ -101,13 +123,11 @@ contract Counter is Typewriter { constructor(address scheduler) Typewriter(scheduler) {} - function dispatch(uint8 mutation, bytes memory mutationData, bytes32) internal override { + function dispatch(uint8 mutation, bytes memory mutationData, bytes32 accountID) internal override { if (mutation == uint8(Mutation.Add)) { - AddMutation.Add memory add = abi.decode(mutationData, (AddMutation.Add)); - state.total += add.amount; + AddMutation.execute(state, abi.decode(mutationData, (AddMutation.Add)), accountID); } else if (mutation == uint8(Mutation.Sub)) { - SubMutation.Sub memory sub = abi.decode(mutationData, (SubMutation.Sub)); - state.total -= sub.amount; + SubMutation.execute(state, abi.decode(mutationData, (SubMutation.Sub)), accountID); } else { revert UnknownMutation(mutation); } @@ -207,23 +227,24 @@ struct Leg { Side side; Price price; } - -struct Place { - uint amount; - int delta; - address payable to; - bool flag; - bytes4 selector; - bytes data; - string memo; - Token token; - Side side; - Price price; - uint256[SIZE] fixedSize; - uint8[2][] nested; - Leg leg; - Leg[] legs; -} +${mutation( + "Place", + ` + uint amount; + int delta; + address payable to; + bool flag; + bytes4 selector; + bytes data; + string memo; + Token token; + Side side; + Price price; + uint256[SIZE] fixedSize; + uint8[2][] nested; + Leg leg; + Leg[] legs;`, +)} ${app("enum Mutation { Place }")}`), ).toMatchInlineSnapshot(` [ @@ -233,7 +254,7 @@ ${app("enum Mutation { Place }")}`), const { Place } = parse(` struct Inner { uint8 side; uint128 price; } -struct Place { Inner leg; Inner[] legs; } +${mutation("Place", "Inner leg; Inner[] legs;")} ${app("enum Mutation { Place }")}`).mutations; expect(Place).toMatchInlineSnapshot(` { @@ -272,21 +293,17 @@ ${app("enum Mutation { Place }")}`).mutations; `); }); -test("parseSolidity finds mutation structs in libraries and imported files", () => { +test("parseSolidity finds mutation libraries in imported files", () => { expect( mutations( ` -import "Mutations.sol"; - -library SubMutation { - struct Sub { int256 amount; } -} +import "Add.sol"; +${mutation("Sub", "int256 amount;")} ${app("enum Mutation { Add, Sub }")}`, { - "Mutations.sol": - "library AddMutation { struct Add { uint256 amount; } }", - // App.sol doesn't import it, so its struct doesn't count. - "Other.sol": "struct Sub { bool unrelated; }", + "Add.sol": mutation("Add"), + // App.sol doesn't import it, so its library doesn't count. + "Other.sol": mutation("Other"), }, ), ).toMatchInlineSnapshot(` @@ -322,7 +339,6 @@ test("parseSolidity errors when the app inherits anything but Typewriter", () => parse(` contract Ownable {} contract App is Typewriter, Ownable { - struct State { uint256 total; } State internal state; constructor(address scheduler) Typewriter(scheduler) {} function dispatch(uint8, bytes memory, bytes32) internal override {} @@ -334,7 +350,7 @@ contract App is Typewriter, Ownable { test("parseSolidity errors when the app contract is abstract", () => { expect(() => - parse(`abstract ${app("enum Mutation { Add } struct Add { uint256 a; }")}`), + parse(`abstract ${app("enum Mutation { Add }")}`), ).toThrowErrorMatchingInlineSnapshot(`"App must not be abstract"`); }); @@ -372,7 +388,6 @@ contract App is Typewriter { expect(() => parse(` contract App is Typewriter { - struct State { uint256 total; } State internal store; constructor(address scheduler) Typewriter(scheduler) {} function dispatch(uint8, bytes memory, bytes32) internal override {} @@ -389,9 +404,6 @@ library Math { function double(uint256 x) external pure returns (uint256) { return 2 * x; } } ${app(` - enum Mutation { Add } - struct Add { uint256 amount; } - function add(uint256 amount) external { state.total += Math.double(amount); }`)}`), @@ -407,7 +419,7 @@ test("parseSolidity errors when the app has no Mutation enum", () => { expect(() => parse(` enum Mutation { Add } -struct Add { uint256 amount; } +${mutation("Add")} ${app("")}`), ).toThrowErrorMatchingInlineSnapshot( `"App must declare \`enum Mutation\` inside the contract, with a member for each mutation"`, @@ -431,26 +443,97 @@ test("parseSolidity errors when Mutation has more members than IDs", () => { ); }); -test("parseSolidity errors when a mutation has no struct, or more than one", () => { +test("parseSolidity errors unless each mutation has one library", () => { expect(() => parse(app("enum Mutation { Add }")), ).toThrowErrorMatchingInlineSnapshot( - `"Mutation.Add takes its params from a struct named Add, but App.sol and its imports define none"`, + `"Mutation.Add needs one library named AddMutation in App.sol or its imports, but there are 0"`, ); + expect(() => + parse( + ` +import "Mutations.sol"; +${app("enum Mutation { Add }")}`, + { + "Mutations.sol": 'import "A.sol" as A;\nimport "B.sol" as B;', + "A.sol": mutation("Add"), + "B.sol": mutation("Add"), + }, + ), + ).toThrowErrorMatchingInlineSnapshot( + `"Mutation.Add needs one library named AddMutation in App.sol or its imports, but there are 2"`, + ); +}); + +test("parseSolidity errors when a mutation library has no Mutation entry", () => { expect(() => parse(` -library AddMutation { struct Add { uint256 amount; } } -library LegacyAddMutation { struct Add { uint128 amount; } } +${mutation("Add")} +${mutation("Sub")} ${app("enum Mutation { Add }")}`), ).toThrowErrorMatchingInlineSnapshot( - `"Mutation.Add takes its params from a struct named Add, but App.sol and its imports define AddMutation.Add, LegacyAddMutation.Add"`, + `"SubMutation has no \`Mutation.Sub\`; add it to the Mutation enum"`, ); }); +test("parseSolidity errors when a mutation library lacks its struct or execute", () => { + const error = (library: string) => { + try { + parse(`${library}\n${app("enum Mutation { Add }")}`); + } catch (error) { + return (error as Error).message; + } + }; + const library = (body: string) => `library AddMutation {\n${body}\n}`; + expect({ + noStruct: error( + library( + "struct Other { uint256 a; }\nfunction execute(State storage, Other memory, bytes32) internal {}", + ), + ), + noExecute: error(library("struct Add { uint256 a; }")), + renamed: error( + library( + "struct Add { uint256 a; }\nfunction executeAdd(State storage, Add memory, bytes32) internal {}", + ), + ), + noAccountID: error( + library( + "struct Add { uint256 a; }\nfunction execute(State storage, Add memory) internal {}", + ), + ), + otherState: error( + library( + "struct Other { uint256 a; }\nstruct Add { uint256 a; }\nfunction execute(Other storage, Add memory, bytes32) internal {}", + ), + ), + calldata: error( + library( + "struct Add { uint256 a; }\nfunction execute(State storage, Add calldata, bytes32) internal {}", + ), + ), + public: error( + library( + "struct Add { uint256 a; }\nfunction execute(State storage, Add memory, bytes32) public {}", + ), + ), + }).toMatchInlineSnapshot(` + { + "calldata": "AddMutation must declare \`struct Add\` with the mutation's params and \`function execute(State storage state, Add memory mutation, bytes32 accountID) internal\`", + "noAccountID": "AddMutation must declare \`struct Add\` with the mutation's params and \`function execute(State storage state, Add memory mutation, bytes32 accountID) internal\`", + "noExecute": "AddMutation must declare \`struct Add\` with the mutation's params and \`function execute(State storage state, Add memory mutation, bytes32 accountID) internal\`", + "noStruct": "AddMutation must declare \`struct Add\` with the mutation's params and \`function execute(State storage state, Add memory mutation, bytes32 accountID) internal\`", + "otherState": "AddMutation must declare \`struct Add\` with the mutation's params and \`function execute(State storage state, Add memory mutation, bytes32 accountID) internal\`", + "public": "AddMutation must declare \`struct Add\` with the mutation's params and \`function execute(State storage state, Add memory mutation, bytes32 accountID) internal\`", + "renamed": "AddMutation must declare \`struct Add\` with the mutation's params and \`function execute(State storage state, Add memory mutation, bytes32 accountID) internal\`", + } + `); +}); + test("parseSolidity errors when a param has no ABI type", () => { expect(() => parse(` -struct Call { function() external fn; } +${mutation("Call", "function() external fn;")} ${app("enum Mutation { Call }")}`), ).toThrowErrorMatchingInlineSnapshot( `"Call.fn has type function () external, which can't be a mutation param"`, diff --git a/packages/typewriter/src/sol-parse.ts b/packages/typewriter/src/sol-parse.ts index 4f498746..b81ceb03 100644 --- a/packages/typewriter/src/sol-parse.ts +++ b/packages/typewriter/src/sol-parse.ts @@ -29,12 +29,15 @@ type AstNode = { id: number; nodeType: string; name: string; - canonicalName: string; abstract: boolean; + contractKind: string; linearizedBaseContracts: number[]; nodes: AstNode[]; members: AstNode[]; sourceUnit: number; + visibility: string; + storageLocation: string; + parameters: { parameters: AstNode[] }; referencedDeclaration: number; typeName: AstNode; baseType: AstNode; @@ -73,11 +76,12 @@ export function parseSolidity(output: SolcOutput, path: string): SolidityApp { }); } - const state = storageLayout.storage.find((item) => item.label === "state"); - if ( - state === undefined || - storageLayout.types[state.type]?.members === undefined - ) { + const stateVariable = contract.nodes.find( + (node) => node.nodeType === "VariableDeclaration" && node.name === "state", + ); + const stateStruct = + stateVariable && nodes.get(stateVariable.typeName.referencedDeclaration); + if (stateStruct?.nodeType !== "StructDefinition") { throw new ConfigError({ message: `${contract.name} must keep its state in a struct variable named \`state\``, }); @@ -104,24 +108,57 @@ export function parseSolidity(output: SolcOutput, path: string): SolidityApp { }); } - const structs = importedStructs(unit, nodes); + const libraries = importedLibraries(unit, nodes).filter(({ name }) => + name.endsWith("Mutation"), + ); + for (const library of libraries) { + const name = library.name.slice(0, -"Mutation".length); + if (!mutationEnum.members.some((member) => member.name === name)) { + throw new ConfigError({ + message: `${library.name} has no \`Mutation.${name}\`; add it to the Mutation enum`, + }); + } + } + const mutations: SolidityApp["mutations"] = {}; - for (const [id, member] of mutationEnum.members.entries()) { - if (Object.hasOwn(BUILTIN_MUTATIONS, member.name)) { + for (const [id, { name }] of mutationEnum.members.entries()) { + if (Object.hasOwn(BUILTIN_MUTATIONS, name)) { throw new ConfigError({ - message: `Mutation.${member.name} is reserved by typewriter; rename it`, + message: `Mutation.${name} is reserved by typewriter; rename it`, }); } - const matches = structs.filter(({ name }) => name === member.name); + const matches = libraries.filter( + (library) => library.name === `${name}Mutation`, + ); if (matches.length !== 1) { throw new ConfigError({ - message: `Mutation.${member.name} takes its params from a struct named ${member.name}, but ${path} and its imports define ${matches.length === 0 ? "none" : matches.map(({ canonicalName }) => canonicalName).join(", ")}`, + message: `Mutation.${name} needs one library named ${name}Mutation in ${path} or its imports, but there are ${matches.length}`, }); } - mutations[member.name] = { - id, - params: structParameters(matches[0]!, nodes), - }; + const library = matches[0]!; + const struct = library.nodes.find( + (node) => node.nodeType === "StructDefinition" && node.name === name, + ); + const signature = `function execute(${stateStruct.name} storage state, ${name} memory mutation, bytes32 accountID) internal`; + const execute = library.nodes.find( + (node) => + node.nodeType === "FunctionDefinition" && + node.name === "execute" && + node.visibility === "internal" && + node.parameters.parameters + .map( + (param) => + `${param.typeName.referencedDeclaration ?? param.typeDescriptions.typeString} ${param.storageLocation}`, + ) + .join() === + `${stateStruct.id} storage,${struct?.id} memory,bytes32 default`, + ); + if (struct === undefined || execute === undefined) { + throw new ConfigError({ + message: `${library.name} must declare \`struct ${name}\` with the mutation's params and \`${signature}\``, + }); + } + mutations[name] = { id, params: structParameters(struct, nodes) }; } for (const [name, { id, params }] of Object.entries(BUILTIN_MUTATIONS)) { mutations[name] = { id, params }; @@ -174,25 +211,25 @@ function findAppContract( return contract; } -/** The structs defined in `unit` and the files it imports. */ -function importedStructs( +/** The libraries defined in `unit` and the files it imports. */ +function importedLibraries( unit: AstNode, nodes: Map, ): AstNode[] { const visited = new Set(); - const structs: AstNode[] = []; + const libraries: AstNode[] = []; const visit = (unit: AstNode) => { if (visited.has(unit.id)) return; visited.add(unit.id); - for (const node of descendants(unit)) { - if (node.nodeType === "StructDefinition") structs.push(node); + for (const node of unit.nodes) { + if (node.contractKind === "library") libraries.push(node); if (node.nodeType === "ImportDirective") { visit(nodes.get(node.sourceUnit)!); } } }; visit(unit); - return structs; + return libraries; } function structParameters( From d5a927b05c8eb822d2d529b0b4588e5bb2c132b1 Mon Sep 17 00:00:00 2001 From: kyscott18 <43524469+kyscott18@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:02:35 +0000 Subject: [PATCH 05/10] refactor(typewriter): lock down the app contract's surface The app contract can't add public or external functions (including fallback and receive) or public variables, and keeps all its storage in state; constants are allowed. execute must return nothing. A library named ...Mutation without a Mutation member is no longer an error: the only library check is one XMutation per Mutation member. Co-Authored-By: Claude Opus 5.5 --- README.md | 11 ++-- packages/typewriter/README.md | 11 ++-- packages/typewriter/src/sol-parse.test.ts | 74 ++++++++++++++++++++--- packages/typewriter/src/sol-parse.ts | 41 +++++++++---- 4 files changed, 108 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index bad253b8..5f9bc782 100644 --- a/README.md +++ b/README.md @@ -253,7 +253,8 @@ entrypoint file or a file it imports, with: permissions, expiration, nonce, and signature checks have already happened in the base contract. -A library named `...Mutation` without a matching `Mutation` member is an error. +`execute` returns nothing and must be `internal`: an external library function +runs in a separately deployed library, which typewriter can't execute offchain. ```solidity library AddMutation { @@ -491,9 +492,11 @@ handled entirely by the base contract. Typewriter doesn't read `dispatch`; it reads each mutation's params from its `XMutation` library. The app contract must inherit only -`Typewriter` and declare `enum Mutation` inside itself. Library functions must -be `internal`: typewriter executes only the app contract, so it rejects an app -that links external libraries. +`Typewriter` and declare `enum Mutation` inside itself. It can't add `public` +or `external` functions or variables, and it keeps all its storage in `state` +(`constant`s are allowed). Library functions must be `internal`: typewriter +executes only the app contract, so it rejects an app that links external +libraries. #### Determinism diff --git a/packages/typewriter/README.md b/packages/typewriter/README.md index 06f87062..7e642236 100644 --- a/packages/typewriter/README.md +++ b/packages/typewriter/README.md @@ -251,7 +251,8 @@ entrypoint file or a file it imports, with: permissions, expiration, nonce, and signature checks have already happened in the base contract. -A library named `...Mutation` without a matching `Mutation` member is an error. +`execute` returns nothing and must be `internal`: an external library function +runs in a separately deployed library, which typewriter can't execute offchain. ```solidity library AddMutation { @@ -489,9 +490,11 @@ handled entirely by the base contract. Typewriter doesn't read `dispatch`; it reads each mutation's params from its `XMutation` library. The app contract must inherit only -`Typewriter` and declare `enum Mutation` inside itself. Library functions must -be `internal`: typewriter executes only the app contract, so it rejects an app -that links external libraries. +`Typewriter` and declare `enum Mutation` inside itself. It can't add `public` +or `external` functions or variables, and it keeps all its storage in `state` +(`constant`s are allowed). Library functions must be `internal`: typewriter +executes only the app contract, so it rejects an app that links external +libraries. #### Determinism diff --git a/packages/typewriter/src/sol-parse.test.ts b/packages/typewriter/src/sol-parse.test.ts index 3723f900..a4cc6260 100644 --- a/packages/typewriter/src/sol-parse.test.ts +++ b/packages/typewriter/src/sol-parse.test.ts @@ -403,10 +403,13 @@ test("parseSolidity errors when the app calls an external library", () => { library Math { function double(uint256 x) external pure returns (uint256) { return 2 * x; } } -${app(` - function add(uint256 amount) external { - state.total += Math.double(amount); - }`)}`), +contract App is Typewriter { + State internal state; + constructor(address scheduler) Typewriter(scheduler) {} + function dispatch(uint8, bytes memory, bytes32) internal override { + state.total = Math.double(state.total); + } +}`), ).toThrowErrorMatchingInlineSnapshot( `"App calls an external library function, which typewriter can't execute offchain; make the library's functions internal"`, ); @@ -465,14 +468,61 @@ ${app("enum Mutation { Add }")}`, ); }); -test("parseSolidity errors when a mutation library has no Mutation entry", () => { - expect(() => - parse(` +test("parseSolidity allows other libraries, constants, and internal functions", () => { + expect( + mutations(` ${mutation("Add")} ${mutation("Sub")} -${app("enum Mutation { Add }")}`), +${app(` + enum Mutation { Add } + uint256 constant LIMIT = 10; + function limit() internal pure returns (uint256) { return LIMIT; } + function check() private pure {}`)}`), + ).toMatchInlineSnapshot(` + [ + "Add(0): uint256 amount", + ] + `); +}); + +test("parseSolidity errors when the app adds entrypoints or state outside state", () => { + const error = (body: string) => { + try { + parse(app(`enum Mutation { Add }\n${body}`), {}); + } catch (error) { + return (error as Error).message; + } + }; + expect({ + publicFunction: error( + "function total() public view returns (uint256) { return state.total; }", + ), + externalFunction: error("function poke() external {}"), + fallback: error("fallback() external {}"), + receive: error("receive() external payable {}"), + publicConstant: error("uint256 public constant LIMIT = 10;"), + variable: error("uint256 internal count;"), + immutable: error("uint256 internal immutable START = 1;"), + }).toMatchInlineSnapshot(` + { + "externalFunction": "App must not add public or external functions or variables, but declares \`poke\`", + "fallback": "App must not add public or external functions or variables, but declares \`fallback\`", + "immutable": "App must keep all its state in \`state\`, but declares \`START\`", + "publicConstant": "App must not add public or external functions or variables, but declares \`LIMIT\`", + "publicFunction": "App must not add public or external functions or variables, but declares \`total\`", + "receive": "App must not add public or external functions or variables, but declares \`receive\`", + "variable": "App must keep all its state in \`state\`, but declares \`count\`", + } + `); + expect(() => + parse(` +contract App is Typewriter { + State public state; + constructor(address scheduler) Typewriter(scheduler) {} + function dispatch(uint8, bytes memory, bytes32) internal override {} +}`), ).toThrowErrorMatchingInlineSnapshot( - `"SubMutation has no \`Mutation.Sub\`; add it to the Mutation enum"`, + `"App must not add public or external functions or variables, but declares \`state\`"`, ); }); @@ -517,6 +567,11 @@ test("parseSolidity errors when a mutation library lacks its struct or execute", "struct Add { uint256 a; }\nfunction execute(State storage, Add memory, bytes32) public {}", ), ), + returns: error( + library( + "struct Add { uint256 a; }\nfunction execute(State storage, Add memory, bytes32) internal returns (bool) { return true; }", + ), + ), }).toMatchInlineSnapshot(` { "calldata": "AddMutation must declare \`struct Add\` with the mutation's params and \`function execute(State storage state, Add memory mutation, bytes32 accountID) internal\`", @@ -526,6 +581,7 @@ test("parseSolidity errors when a mutation library lacks its struct or execute", "otherState": "AddMutation must declare \`struct Add\` with the mutation's params and \`function execute(State storage state, Add memory mutation, bytes32 accountID) internal\`", "public": "AddMutation must declare \`struct Add\` with the mutation's params and \`function execute(State storage state, Add memory mutation, bytes32 accountID) internal\`", "renamed": "AddMutation must declare \`struct Add\` with the mutation's params and \`function execute(State storage state, Add memory mutation, bytes32 accountID) internal\`", + "returns": "AddMutation must declare \`struct Add\` with the mutation's params and \`function execute(State storage state, Add memory mutation, bytes32 accountID) internal\`", } `); }); diff --git a/packages/typewriter/src/sol-parse.ts b/packages/typewriter/src/sol-parse.ts index b81ceb03..e659d70b 100644 --- a/packages/typewriter/src/sol-parse.ts +++ b/packages/typewriter/src/sol-parse.ts @@ -38,6 +38,9 @@ type AstNode = { visibility: string; storageLocation: string; parameters: { parameters: AstNode[] }; + returnParameters: { parameters: AstNode[] }; + kind: string; + mutability: string; referencedDeclaration: number; typeName: AstNode; baseType: AstNode; @@ -87,6 +90,30 @@ export function parseSolidity(output: SolcOutput, path: string): SolidityApp { }); } + for (const node of contract.nodes) { + const isVariable = node.nodeType === "VariableDeclaration"; + if ( + isVariable && + node !== stateVariable && + node.mutability !== "constant" + ) { + throw new ConfigError({ + message: `${contract.name} must keep all its state in \`state\`, but declares \`${node.name}\``, + }); + } + const isEntrypoint = + node.visibility === "public" || node.visibility === "external"; + if ( + isEntrypoint && + (isVariable || + (node.nodeType === "FunctionDefinition" && node.kind !== "constructor")) + ) { + throw new ConfigError({ + message: `${contract.name} must not add public or external functions or variables, but declares \`${node.name || node.kind}\``, + }); + } + } + const bytecode = `0x${evm.bytecode.object}`; if (!Hex.validate(bytecode, { strict: true })) { throw new ConfigError({ @@ -108,18 +135,7 @@ export function parseSolidity(output: SolcOutput, path: string): SolidityApp { }); } - const libraries = importedLibraries(unit, nodes).filter(({ name }) => - name.endsWith("Mutation"), - ); - for (const library of libraries) { - const name = library.name.slice(0, -"Mutation".length); - if (!mutationEnum.members.some((member) => member.name === name)) { - throw new ConfigError({ - message: `${library.name} has no \`Mutation.${name}\`; add it to the Mutation enum`, - }); - } - } - + const libraries = importedLibraries(unit, nodes); const mutations: SolidityApp["mutations"] = {}; for (const [id, { name }] of mutationEnum.members.entries()) { if (Object.hasOwn(BUILTIN_MUTATIONS, name)) { @@ -145,6 +161,7 @@ export function parseSolidity(output: SolcOutput, path: string): SolidityApp { node.nodeType === "FunctionDefinition" && node.name === "execute" && node.visibility === "internal" && + node.returnParameters.parameters.length === 0 && node.parameters.parameters .map( (param) => From 13340c2e4056a66bbe288fa92aedb68dec3ff9b7 Mon Sep 17 00:00:00 2001 From: kyscott18 <43524469+kyscott18@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:13:24 +0000 Subject: [PATCH 06/10] docs: keep the typewriter README to what app developers need Drop internals (revm, storage proxies, slot-write rehydration, preimages, the reorg algorithm, forge/AST parsing, the scheduler-only execute ABI) and list the app contract rules in one place. Fix two stale examples: the Concepts mutation now follows the XMutation library convention, and the FORCE_INCLUSION_DELAY constructor passes scheduler to Typewriter. The root README had drifted to document features that no longer exist (safe status, onFatalError, mutation id); it is now a copy of the package README. Co-Authored-By: Claude Opus 5.5 --- README.md | 187 +++++++++++++++------------------- packages/typewriter/README.md | 122 +++++++++------------- 2 files changed, 131 insertions(+), 178 deletions(-) diff --git a/README.md b/README.md index 5f9bc782..93d0a435 100644 --- a/README.md +++ b/README.md @@ -35,15 +35,17 @@ struct State { Mutations are app-defined state transitions requested by a user and executed onchain. Updates to application state are done with mutations. ```solidity -struct Transfer { - bytes32 to; - uint256 amount; -} +library TransferMutation { + struct Transfer { + bytes32 to; + uint256 amount; + } -function executeTransfer(State storage state, Transfer memory transfer, bytes32 accountID) { - state.balances[accountID] -= transfer.amount; - unchecked { - state.balances[transfer.to] += transfer.amount; + function execute(State storage state, Transfer memory transfer, bytes32 accountID) internal { + state.balances[accountID] -= transfer.amount; + unchecked { + state.balances[transfer.to] += transfer.amount; + } } } ``` @@ -60,18 +62,17 @@ Accounts use two-dimensional nonces: a lane and a sequence number within that la ### Server runtime -A mutation is the framework equivalent of a transaction, but unlike a transaction it is not submitted directly onchain. Instead mutations are submitted to an application server where they can be reordered and accepted quickly, then eventually made durable with onchain execution. Users sign mutations, not transactions; in normal operation the server is the party that submits transactions to the chain. Each mutation is executed locally in an embedded EVM (revm) against the server's copy of contract state, and once the server knows the mutation will succeed onchain it responds `accepted` — usually within milliseconds, before a block is produced. +A mutation is the framework equivalent of a transaction, but unlike a transaction it is not submitted directly onchain. Instead mutations are submitted to an application server where they can be reordered and accepted quickly, then eventually made durable with onchain execution. Users sign mutations, not transactions; in normal operation the server is the party that submits transactions to the chain. The server executes each mutation against its own copy of the contract's state, and once it knows the mutation will succeed onchain it responds `accepted` — usually within milliseconds, before a block is produced. The lifecycle of a mutation is as follows: - **received**. The server has received the mutation. - **accepted**. The server has ordered and executed the mutation locally. - **included**. The server submitted the mutation onchain and it has been included in a block. -- **safe**. The block that contains the mutation has been marked "safe" by consensus. (See JSON-RPC "safe" tag). -- **finalized**. The block that contains the mutation has been marked "finalized" by consensus. (See JSON-RPC "finalized" tag). +- **finalized**. The block that contains the mutation is `confirmations.finalizedBlockDepth` blocks deep. `createTypewriter` starts the runtime and returns a handle for submitting mutations, reading state, and subscribing to events. -`createTypewriter` takes a Solidity entrypoint and runtime config. `TypewriterConfig` requires `address`, `account`, `chainId`, `rpcUrl`, and `database`, or just `database` in [dev mode](#local-development). Contract metadata (`storageLayout` and app mutations) is derived from the Solidity entrypoint; native account mutations are added automatically. Optional runtime controls are `blockPollingIntervalMs`, `confirmations`, `onFatalError`, and `sequencing`. +`createTypewriter` takes a Solidity entrypoint and runtime config. `TypewriterConfig` requires `address`, `account`, `chainId`, `rpcUrl`, and `database`, or just `database` in [dev mode](#local-development). Contract metadata (`storageLayout` and app mutations) is derived from the Solidity entrypoint; native account mutations are added automatically. Optional runtime controls are `blockPollingIntervalMs`, `confirmations`, and `sequencing`. ```ts import { createTypewriter } from "typewriter"; @@ -85,7 +86,6 @@ const typewriter = await createTypewriter(Token, { rpcUrl, database: { url: databaseUrl }, confirmations: { - safeBlockDepth: 1, finalizedBlockDepth: 5, }, blockPollingIntervalMs: 200, @@ -181,11 +181,9 @@ anvil --network monad --block-time 0.3 A mutation can be included in a block and later removed by a [chain reorganization](https://www.alchemy.com/overviews/what-is-a-reorg). By definition, this only affects mutations that have not reached the `finalized` state. -Reorgs are detected on the server by requesting new blocks with JSON-RPC requests and reconciling them into the local view of the chain. The specific algorithm used to detect reorgs is inspired by [Ponder](https://github.com/ponder-sh/ponder), where it has been used in production for millions of hours, cumulatively. - -When a reorg is detected, mutations may move backwards in the mutation lifecycle (submitted => accepted => included => safe => finalized). They are re-processed against the new canonical chain and re-emitted with their updated lifecycle status. +When a reorg is detected, mutations may move backwards in the mutation lifecycle (accepted => included => finalized). They are re-processed against the new canonical chain and re-emitted with their updated lifecycle status. -> Coming soon: As a protection against exposing user-facing data that may be reorged, apps can choose to use later lifecycle stages, such as `"included"`, `"safe"`, or `"finalized"`, to serve read requests. +> Coming soon: As a protection against exposing user-facing data that may be reorged, apps can choose to use later lifecycle stages, such as `"included"` or `"finalized"`, to serve read requests. ### Force inclusion @@ -193,7 +191,7 @@ Users can bypass the server entirely and submit their mutations directly to the Force inclusion uses a two-stage, enqueue then execute, mechanism. First, a user registers their mutation onchain by calling the `enqueue()` function. -When the server detects a force-included mutation, it reconciles the force included mutation with its local state view, then includes the mutation onchain. +When the server sees an enqueued mutation, it executes it and settles it onchain like any other mutation. If the server is completely offline, the user may call `forceExecute()` on their queued mutation after the `FORCE_INCLUSION_DELAY`. @@ -205,13 +203,13 @@ This can happen when the execution depends on EVM environment opcodes whose valu When a server-submitted transaction reverts, "accepted" mutations may move backwards in the mutation lifecycle (to "received"). They are re-processed against the current canonical chain state and re-emitted with their updated lifecycle status. From the user's perspective, this is no different than a chain reorganization. -The server execution environment uses values from the last known block for EVM environment opcodes. Certain EVM environment opcodes, such as `COINBASE` or `PREVRANDAO`, can make divergence more likely or even guaranteed. For a full list of unsupported opcodes, see [contract requirements](#contract-requirements). +Certain EVM environment opcodes, such as `COINBASE` or `PREVRANDAO`, make divergence likely or even guaranteed. See [Determinism](#determinism) for the full list. > Coming soon: If onchain execution results in a different state transition than the server accepted, the transaction will revert instead of settling a mismatched result. ### Crash recovery -The server can be restarted at any time without losing accepted state. Every mutation and raw slot write is persisted to Postgres as it happens. On startup, the runtime rehydrates its local execution environment from the persisted slot writes. +The server can be restarted at any time without losing accepted mutations: they are persisted to Postgres as they are accepted, and the server restores its state from them on startup. Mutations that were accepted before the crash but had not yet reached `finalized` are reconciled with the underlying chain on restart. @@ -224,9 +222,8 @@ In order to be Typewriter-compliant, a smart contract must be written with Solid #### State All application state is contained in one `struct State` stored in a top-level -variable named `state`. Typewriter stores its account registry separately in a -top-level `accounts` mapping inherited from the base contract. Compiler storage -layout gives the runtime typed proxies for both roots. +variable named `state`. Typewriter keeps its account registry separately, in an +`accounts` mapping inherited from the base contract. ```solidity struct State { @@ -235,7 +232,7 @@ struct State { } ``` -> Value types, enums, structs, fixed and dynamic arrays, mappings, `bytes`, and `string` are supported, nested in any combination. Fixed-point numbers, function types, contract and interface types, user-defined value types, and `bytes`/`string`/enum mapping keys are not supported and fail loudly. The runtime stores the 64-byte keccak256 preimages from each accepted execution, so `Object.keys` on a mapping lists every key that an accepted mutation hashed. +> Value types, enums, structs, fixed and dynamic arrays, mappings, `bytes`, and `string` are supported, nested in any combination. Fixed-point numbers, function types, contract and interface types, user-defined value types, and `bytes`/`string`/enum mapping keys are not supported and fail loudly. `Object.keys` on a mapping lists the keys that accepted mutations have read or written. #### Mutations @@ -253,8 +250,7 @@ entrypoint file or a file it imports, with: permissions, expiration, nonce, and signature checks have already happened in the base contract. -`execute` returns nothing and must be `internal`: an external library function -runs in a separately deployed library, which typewriter can't execute offchain. +`execute` returns nothing and must be `internal`. ```solidity library AddMutation { @@ -365,7 +361,7 @@ address matching the stored address. For `P256`, it verifies `(r, s)` over `sha256(digest)`. For `WebAuthnP256`, it checks that the 43-byte, unpadded base64url encoding of `digest` appears in `clientDataJSON` at `challengeOffset`, then verifies `(r, s)` over `sha256(authData || sha256(clientDataJSON))`. -Both P-256 paths use the precompile at `0x100` and require it to return `1`. +Both P-256 paths need the chain's P-256 precompile at `0x100`, which Monad provides. **Verification.** For an existing account, the base contract performs these checks before executing the mutation: @@ -400,10 +396,10 @@ signature must be nonempty, and `accountID` must equal #### Contract structure -The contract inherits from `typewriter/Typewriter.sol`, which supplies native -account storage and validation, the Typewriter EIP-712 domain, scheduler and -force-inclusion protocol, and the external execution entry points. The app owns -only its `State`, app mutation enum, and the `dispatch` callback. +The contract inherits from `typewriter/Typewriter.sol`, which supplies accounts +and authorization, the scheduler, and force inclusion. The app owns only its +`state`, its `Mutation` enum, its mutation libraries, and the `dispatch` +callback. ```solidity import {Typewriter, UnknownMutation} from "typewriter/Typewriter.sol"; @@ -430,43 +426,34 @@ contract Token is Typewriter { } ``` -**`state`.** A single non-public storage variable wraps the State struct. Underlying storage slots are read directly so Solidity visibility does not affect framework reads. - -**`Mutation` enum.** Designates the valid mutations for the contract. +The app contract: -**`SCHEDULER`.** The privileged address for submitting mutations. Declared in `Typewriter` as `address internal immutable` and assigned by the `Typewriter` constructor. Every app has the same constructor, `constructor(address scheduler)`, and passes `scheduler` to `Typewriter(scheduler)`; it may do more in the body, but takes no other arguments, so typewriter can deploy any app. +- inherits only `Typewriter` and isn't abstract; +- takes exactly `constructor(address scheduler)` and passes `scheduler` to `Typewriter(scheduler)`, so typewriter can deploy any app; the body may do more; +- keeps all its storage in the non-public `state` variable (`constant`s are allowed); +- declares `enum Mutation`, with one mutation library per member, as described in Mutations above; +- adds no `public` or `external` functions; +- calls only `internal` library functions. -**`DOMAIN_SEPARATOR`.** Constructed by the base contract from the fixed -Typewriter name/version, `block.chainid`, and `address(this)`. +Typewriter checks these rules at startup and names the one that's broken. -**`FORCE_INCLUSION_DELAY`.** The number of seconds that must elapse after a mutation is enqueued before any caller may `forceExecute` it. Declared in `Typewriter` as `uint256 internal immutable`, initialized to `5 minutes`. An app that needs a different delay reassigns it in its constructor: +**`FORCE_INCLUSION_DELAY`.** The number of seconds that must elapse after a mutation is enqueued before any caller may `forceExecute` it. It defaults to `5 minutes`; an app that needs a different delay reassigns it in its constructor: ```solidity -constructor(address _scheduler) { - SCHEDULER = _scheduler; +constructor(address scheduler) Typewriter(scheduler) { FORCE_INCLUSION_DELAY = 10 minutes; } ``` -**Inherited external ABI.** `execute`, `enqueue`, and `forceExecute` are implemented by `Typewriter` (no app code), but they define the contract's external surface that the runtime and clients depend on: +**Force inclusion.** `Typewriter` implements `enqueue` and `forceExecute`, which let users bypass the server (see [Force inclusion](#force-inclusion)): ```solidity -// Scheduler-gated settlement. `forceExecuteIndexes` settles queued -// force-included mutations alongside the batches. -function execute(Batch[] calldata batches, uint256[] calldata forceExecuteIndexes) external; - -// User force-inclusion entry point. Pushes a `QueuedMutation` and emits -// `ForceInclusionQueued`. +// Queues a mutation onchain and emits `ForceInclusionQueued`. function enqueue(uint8 mutation, bytes calldata mutationData, bytes calldata authorizationData) external returns (uint256); -// Un-gated escape hatch, callable by anyone once `FORCE_INCLUSION_DELAY` seconds -// have elapsed since the entry was enqueued. +// Callable by anyone once `FORCE_INCLUSION_DELAY` seconds have elapsed since the entry was enqueued. function forceExecute(uint256 index) external; -``` - -`enqueue` emits `ForceInclusionQueued`, which clients and watchers decode to discover queued mutations: -```solidity event ForceInclusionQueued( uint256 index, uint8 mutation, @@ -483,20 +470,11 @@ function dispatch(uint8 mutation, bytes memory mutationData, bytes32 accountID) internal override; ``` -For app mutations, Typewriter first validates the account, credential, -expiration, permission, and nonce. It verifies the original `mutationData` -through the fixed authorization envelope, consumes the nonce, and calls -`dispatch` with the authenticated account ID. The callback branches on the app -mutation ID and ABI-decodes the mutation struct. Built-in account mutations are -handled entirely by the base contract. - -Typewriter doesn't read `dispatch`; it reads each mutation's params from its -`XMutation` library. The app contract must inherit only -`Typewriter` and declare `enum Mutation` inside itself. It can't add `public` -or `external` functions or variables, and it keeps all its storage in `state` -(`constant`s are allowed). Library functions must be `internal`: typewriter -executes only the app contract, so it rejects an app that links external -libraries. +For app mutations, Typewriter first [verifies the authorization](#authorization) +and consumes the nonce, then calls `dispatch` with the authenticated account ID. +`dispatch` branches on the mutation ID, ABI-decodes `mutationData` into the +mutation's struct, and calls its library's `execute`. Built-in account +mutations are handled entirely by the base contract. #### Determinism @@ -506,7 +484,7 @@ Opcodes whose values can't be reproduced offchain produce divergence between off - `block.difficulty` / `block.prevrandao` (`PREVRANDAO`) — post-merge randomness is set by the block proposer. - `blockhash(n)` (`BLOCKHASH`) for blocks the server hasn't seen. - `blobhash(i)` (`BLOBHASH`) and `block.blobbasefee` (`BLOBBASEFEE`) — blob context belongs to the submitting transaction, not the mutation. -- `gasleft()` (`GAS`) — gas accounting in revm does not match the live EVM, so any branch on remaining gas can diverge. +- `gasleft()` (`GAS`) — the server's gas accounting doesn't match the chain's, so any branch on remaining gas can diverge. `block.number` and `block.timestamp` are approximations — not guaranteed to match the block a mutation settles in. The server executes accepted mutations against the last known block's values, so what a mutation sees lags the block of inclusion by at least one. They're fine for coarse-grained checks (expiries, epoch windows) but unsafe for tight windows, sub-block timing, or logic that branches on the exact block of inclusion. @@ -528,7 +506,7 @@ function createTypewriter( ): Promise; ``` -Starts the runtime and resolves to the `Typewriter` handle (see the [Server runtime](#server-runtime) example for a full call). On startup it connects to the chain and database, runs migrations, and hydrates local revm state from persisted slot writes; from there it accepts mutations and submits them onchain. +Starts the runtime and resolves to the `Typewriter` handle once it is ready to accept mutations (see the [Server runtime](#server-runtime) example for a full call). **`TypewriterConfig`.** Required fields, except in [dev mode](#local-development) (`dev: true`), where typewriter fills in all but `database`: @@ -538,9 +516,9 @@ Starts the runtime and resolves to the `Typewriter` handle (see the [Server runt - `rpcUrl` — `string | string[]`. - `database` — `{ url, maxConnections? }` (Postgres). -Optional runtime controls: `blockPollingIntervalMs` (default `200`), `confirmations` (`{ safeBlockDepth?, finalizedBlockDepth? }`, defaults `1` / `5`), `onFatalError` (`(error) => void`; without it a fatal runtime error is rethrown), and `sequencing` (see [Sequencing](#sequencing); defaults to FIFO). +Optional runtime controls: `blockPollingIntervalMs` (default `200`), `confirmations` (`{ finalizedBlockDepth? }`, default `5`), and `sequencing` (see [Sequencing](#sequencing); defaults to FIFO). -The Solidity entrypoint must be importable by Bun. At startup, typewriter runs `forge build`, reads the compiled ABI/storage layout/AST, and derives app mutation IDs, params, and typed storage from the contract. +The Solidity entrypoint must be importable by Bun and belong to a Foundry project, with `forge` on the `PATH`. At startup, typewriter builds the project and derives mutation IDs, params, and typed storage from the contract. **Logging.** Two environment variables control runtime logs: @@ -591,13 +569,15 @@ const submitted = authorizeMutation(mutation, signature); #### `typewriter.execute()` ```ts -typewriter.execute(input: { name, params, authorization }): Promise<{ id }>; +typewriter.execute(input: { name, params, authorization }): Promise<{ executionIndex }>; ``` Submits an authorized mutation. `name` is the exact Solidity `Mutation` enum member (or built-in mutation name), `params` matches its struct, and `authorization` has the fixed native shape. It resolves once accepted and -rejects if contract execution reverts. +rejects if contract execution reverts. `executionIndex` (`bigint`) is the +mutation's position in the onchain execution order and the primary key of its +row in the mutation tables. ```ts const accepted = await typewriter.execute({ @@ -605,7 +585,7 @@ const accepted = await typewriter.execute({ params, authorization, }); -// accepted.id +// accepted.executionIndex ``` #### `typewriter.state` @@ -614,10 +594,9 @@ const accepted = await typewriter.execute({ typewriter.state: StorageView; ``` -`typewriter.state` resolves the top-level Solidity `state` variable by one proxy -layer. It reads the runtime's local revm-backed storage mirror, so accepted state -is visible before onchain inclusion. Leaf accesses are synchronous and return -values. +`typewriter.state` reads the app's `state` variable as the server has accepted +it, so a mutation's effects are visible as soon as it is accepted, before +onchain inclusion. ```ts const balance = typewriter.state.balances[accountID]; @@ -627,9 +606,8 @@ const balance = typewriter.state.balances[accountID]; #### `typewriter.accounts` -`typewriter.accounts` resolves the base contract's top-level native account -mapping by one proxy layer. Apps can inspect learned account keys, credentials, -and nonce lanes without placing account state inside their own `State` struct. +`typewriter.accounts` reads the base contract's `accounts` mapping, so apps can +inspect account credentials and nonce lanes. ```ts const account = typewriter.accounts[accountID]; @@ -640,32 +618,28 @@ const sequence = await account.nonces[lane]; #### `typewriter.schema` ```ts -typewriter.schema: TypewriterSchema; +typewriter.schema: TypewriterMutationSchema; ``` The [Drizzle](https://orm.drizzle.team) tables the runtime generates and migrates for this deployment. typewriter owns these tables; apps read from them to build read models (decoded mutation history, per-account views, and so on). -One table per Solidity `Mutation` enum member, named `_mutations` with the runtime mutation name lowercased — `Mutation.Transfer` becomes `typewriter.schema.transfer_mutations`, `Mutation.MarketOrder` becomes `marketorder_mutations`. A row is written when a mutation is `accepted` and updated as it advances; `received` and `rejected` mutations are not persisted here. +One table per Solidity `Mutation` enum member, named `_mutations` with the runtime mutation name lowercased — `Mutation.Transfer` becomes `typewriter.schema.transfer_mutations`, `Mutation.MarketOrder` becomes `marketorder_mutations`. A row is written when a mutation is `accepted`, and its block columns are set when it is `included`; `received` and `rejected` mutations are not persisted here. A mutation is finalized once its `block_number` is `confirmations.finalizedBlockDepth` blocks behind the chain head. The tables live in the Postgres schema `typewriter__
`. Every mutation table starts with the same **lifecycle columns**: | Column | Type | Notes | | --- | --- | --- | -| `id` | `integer`, primary key | mutation id; matches the `typewriter.execute()` result and event `id` | -| `status` | `mutation_status` enum | one of `accepted`, `included`, `safe`, `finalized` | -| `executionIndex` | `numeric(78,0)` bigint | speculative onchain execution index; assigned and persisted at acceptance | -| `blockNumber` | `numeric(78,0)` bigint | inclusion block; null until included | -| `blockHash` | `char(66)` (hex) | null until included | -| `blockTimestamp` | `numeric(78,0)` bigint | null until included | -| `transactionHash` | `char(66)` (hex) | the settlement transaction; null until included | -| `acceptedAt` | `timestamp`, defaults to now | | -| `includedAt` | `timestamp` | null until included | -| `safeAt` | `timestamp` | null until safe | -| `finalizedAt` | `timestamp` | null until finalized | +| `execution_index` | `bigint`, primary key | the mutation's position in the onchain execution order; matches the `typewriter.execute()` result and event `executionIndex` | +| `accepted_at` | `timestamp with time zone`, defaults to now | | +| `block_number` | `bigint` | inclusion block; null until included | +| `block_hash` | `char(66)` (hex) | null until included | +| `block_timestamp` | `bigint` | null until included | +| `transaction_index` | `integer` | null until included | +| `transaction_hash` | `char(66)` (hex) | the settlement transaction; null until included | Then two groups of payload columns: -- **Param columns** — one per ABI parameter in the mutation struct decoded by `dispatch`, named after the parameter (an unnamed parameter becomes `arg`). +- **Param columns** — one per field of the mutation's struct, named after the field. - **Authorization columns** — fixed `authorization_account_id`, `authorization_credential_id`, `authorization_nonce`, `authorization_expiration`, and `authorization_signature` columns. @@ -697,7 +671,7 @@ import { desc } from "drizzle-orm"; const recent = await db .select() .from(typewriter.schema.transfer_mutations) - .orderBy(desc(typewriter.schema.transfer_mutations.id)) + .orderBy(desc(typewriter.schema.transfer_mutations.execution_index)) .limit(20); ``` @@ -709,29 +683,28 @@ typewriter.on(event, callback): () => void; Subscribes to runtime events; returns an unsubscribe function. There are three events, each with its own payload type. -**`"mutation"` → `MutationEvent`** — fires every time a mutation changes lifecycle status. It is a discriminated union on `status`. Every variant carries `id` (number), `name` (the mutation name), `params`, and `authorization` (including `authorization.signature`); the rest depends on `status`: +**`"mutation"` → `MutationEvent`** — fires every time a mutation changes lifecycle status. It is a discriminated union on `status`. Every variant carries `name` (the mutation name), `params`, and `authorization` (including `authorization.signature`); the rest depends on `status`: | `status` | Additional fields | | --- | --- | | `"received"` | — | -| `"enqueued"` | — | -| `"accepted"` | `isForceInclusion` | -| `"included"` / `"safe"` / `"finalized"` | `isForceInclusion` | +| `"enqueued"` | `isForceInclusion` (`true`), `queueIndex` | +| `"accepted"` | `isForceInclusion`, `executionIndex` | +| `"included"` / `"finalized"` | `isForceInclusion`, `executionIndex`, `blockNumber`, `blockHash`, `blockTimestamp`, `transactionHash`, `transactionIndex` | | `"rejected"` | `isForceInclusion`, `error` | -This status set is wider than the happy-path lifecycle in [Server runtime](#server-runtime): `enqueued` occurs for force-included mutations — those a user submits directly onchain via `enqueue()` (see [Force inclusion](#force-inclusion)) — once the runtime detects and reconciles them, and `rejected` (carrying the rejection `error`) when a mutation fails. `isForceInclusion` marks mutations that entered through the force-inclusion queue. +This status set is wider than the happy-path lifecycle in [Server runtime](#server-runtime): `enqueued` occurs for force-included mutations — those a user submits directly onchain via `enqueue()` (see [Force inclusion](#force-inclusion)) — once the runtime detects and reconciles them, and `rejected` (carrying the rejection `error`) when a mutation fails. `isForceInclusion` marks mutations that entered through the force-inclusion queue; those also carry their `queueIndex`. `executionIndex` and the block fields match the `execution_index`, `block_*` and `transaction_*` columns of the mutation's row. **`"batch"` → `BatchEvent`** — fires when a batch changes status; batch sequencing only. Fields: -- `status` — `"accepted" | "included" | "safe" | "finalized"` +- `status` — `"accepted" | "included" | "finalized"` - `id` — number - `position` — the batch's position in submission order -- `mutations` — the batch's `MutationEvent`s (`accepted` and later; never `enqueued`/`rejected`) -- `forceIncludedMutations?` — force-included mutations settled alongside the batch +- `mutations` — the batch's `MutationEvent`s, with the same `status` as the batch **`"block"` → `BlockEvent`** — fires when a submitted block reaches a confirmation depth. Common fields: -- `status` — `"included" | "safe" | "finalized"` (block events never fire for `accepted`, which is pre-block) +- `status` — `"included" | "finalized"` (block events never fire for `accepted`, which is pre-block) - `number` — block number (`bigint`) - `hash` — block hash - `timestamp` — block timestamp (`bigint`) @@ -742,9 +715,11 @@ The remaining fields depend on sequencing: - FIFO — `mutations`: the block's `MutationEvent`s. - batch — `batches`: the block's `BatchEvent`s (excluding `accepted`); and `forceIncludedMutations`. +With FIFO sequencing, `on("batch", ...)` is a type error. + ```ts const unsubscribe = typewriter.on("mutation", (mutation) => { - console.log(mutation.id, mutation.status); + console.log(mutation.name, mutation.status); }); ``` diff --git a/packages/typewriter/README.md b/packages/typewriter/README.md index 7e642236..93d0a435 100644 --- a/packages/typewriter/README.md +++ b/packages/typewriter/README.md @@ -35,15 +35,17 @@ struct State { Mutations are app-defined state transitions requested by a user and executed onchain. Updates to application state are done with mutations. ```solidity -struct Transfer { - bytes32 to; - uint256 amount; -} +library TransferMutation { + struct Transfer { + bytes32 to; + uint256 amount; + } -function executeTransfer(State storage state, Transfer memory transfer, bytes32 accountID) { - state.balances[accountID] -= transfer.amount; - unchecked { - state.balances[transfer.to] += transfer.amount; + function execute(State storage state, Transfer memory transfer, bytes32 accountID) internal { + state.balances[accountID] -= transfer.amount; + unchecked { + state.balances[transfer.to] += transfer.amount; + } } } ``` @@ -60,7 +62,7 @@ Accounts use two-dimensional nonces: a lane and a sequence number within that la ### Server runtime -A mutation is the framework equivalent of a transaction, but unlike a transaction it is not submitted directly onchain. Instead mutations are submitted to an application server where they can be reordered and accepted quickly, then eventually made durable with onchain execution. Users sign mutations, not transactions; in normal operation the server is the party that submits transactions to the chain. Each mutation is executed locally in an embedded EVM (revm) against the server's copy of contract state, and once the server knows the mutation will succeed onchain it responds `accepted` — usually within milliseconds, before a block is produced. +A mutation is the framework equivalent of a transaction, but unlike a transaction it is not submitted directly onchain. Instead mutations are submitted to an application server where they can be reordered and accepted quickly, then eventually made durable with onchain execution. Users sign mutations, not transactions; in normal operation the server is the party that submits transactions to the chain. The server executes each mutation against its own copy of the contract's state, and once it knows the mutation will succeed onchain it responds `accepted` — usually within milliseconds, before a block is produced. The lifecycle of a mutation is as follows: - **received**. The server has received the mutation. @@ -179,8 +181,6 @@ anvil --network monad --block-time 0.3 A mutation can be included in a block and later removed by a [chain reorganization](https://www.alchemy.com/overviews/what-is-a-reorg). By definition, this only affects mutations that have not reached the `finalized` state. -Reorgs are detected on the server by requesting new blocks with JSON-RPC requests and reconciling them into the local view of the chain. The specific algorithm used to detect reorgs is inspired by [Ponder](https://github.com/ponder-sh/ponder), where it has been used in production for millions of hours, cumulatively. - When a reorg is detected, mutations may move backwards in the mutation lifecycle (accepted => included => finalized). They are re-processed against the new canonical chain and re-emitted with their updated lifecycle status. > Coming soon: As a protection against exposing user-facing data that may be reorged, apps can choose to use later lifecycle stages, such as `"included"` or `"finalized"`, to serve read requests. @@ -191,7 +191,7 @@ Users can bypass the server entirely and submit their mutations directly to the Force inclusion uses a two-stage, enqueue then execute, mechanism. First, a user registers their mutation onchain by calling the `enqueue()` function. -When the server detects a force-included mutation, it reconciles the force included mutation with its local state view, then includes the mutation onchain. +When the server sees an enqueued mutation, it executes it and settles it onchain like any other mutation. If the server is completely offline, the user may call `forceExecute()` on their queued mutation after the `FORCE_INCLUSION_DELAY`. @@ -203,13 +203,13 @@ This can happen when the execution depends on EVM environment opcodes whose valu When a server-submitted transaction reverts, "accepted" mutations may move backwards in the mutation lifecycle (to "received"). They are re-processed against the current canonical chain state and re-emitted with their updated lifecycle status. From the user's perspective, this is no different than a chain reorganization. -The server execution environment uses values from the last known block for EVM environment opcodes. Certain EVM environment opcodes, such as `COINBASE` or `PREVRANDAO`, can make divergence more likely or even guaranteed. For a full list of unsupported opcodes, see [contract requirements](#contract-requirements). +Certain EVM environment opcodes, such as `COINBASE` or `PREVRANDAO`, make divergence likely or even guaranteed. See [Determinism](#determinism) for the full list. > Coming soon: If onchain execution results in a different state transition than the server accepted, the transaction will revert instead of settling a mismatched result. ### Crash recovery -The server can be restarted at any time without losing accepted state. Every mutation and raw slot write is persisted to Postgres as it happens. On startup, the runtime rehydrates its local execution environment from the persisted slot writes. +The server can be restarted at any time without losing accepted mutations: they are persisted to Postgres as they are accepted, and the server restores its state from them on startup. Mutations that were accepted before the crash but had not yet reached `finalized` are reconciled with the underlying chain on restart. @@ -222,9 +222,8 @@ In order to be Typewriter-compliant, a smart contract must be written with Solid #### State All application state is contained in one `struct State` stored in a top-level -variable named `state`. Typewriter stores its account registry separately in a -top-level `accounts` mapping inherited from the base contract. Compiler storage -layout gives the runtime typed proxies for both roots. +variable named `state`. Typewriter keeps its account registry separately, in an +`accounts` mapping inherited from the base contract. ```solidity struct State { @@ -233,7 +232,7 @@ struct State { } ``` -> Value types, enums, structs, fixed and dynamic arrays, mappings, `bytes`, and `string` are supported, nested in any combination. Fixed-point numbers, function types, contract and interface types, user-defined value types, and `bytes`/`string`/enum mapping keys are not supported and fail loudly. The runtime stores the 64-byte keccak256 preimages from each accepted execution, so `Object.keys` on a mapping lists every key that an accepted mutation hashed. +> Value types, enums, structs, fixed and dynamic arrays, mappings, `bytes`, and `string` are supported, nested in any combination. Fixed-point numbers, function types, contract and interface types, user-defined value types, and `bytes`/`string`/enum mapping keys are not supported and fail loudly. `Object.keys` on a mapping lists the keys that accepted mutations have read or written. #### Mutations @@ -251,8 +250,7 @@ entrypoint file or a file it imports, with: permissions, expiration, nonce, and signature checks have already happened in the base contract. -`execute` returns nothing and must be `internal`: an external library function -runs in a separately deployed library, which typewriter can't execute offchain. +`execute` returns nothing and must be `internal`. ```solidity library AddMutation { @@ -363,7 +361,7 @@ address matching the stored address. For `P256`, it verifies `(r, s)` over `sha256(digest)`. For `WebAuthnP256`, it checks that the 43-byte, unpadded base64url encoding of `digest` appears in `clientDataJSON` at `challengeOffset`, then verifies `(r, s)` over `sha256(authData || sha256(clientDataJSON))`. -Both P-256 paths use the precompile at `0x100` and require it to return `1`. +Both P-256 paths need the chain's P-256 precompile at `0x100`, which Monad provides. **Verification.** For an existing account, the base contract performs these checks before executing the mutation: @@ -398,10 +396,10 @@ signature must be nonempty, and `accountID` must equal #### Contract structure -The contract inherits from `typewriter/Typewriter.sol`, which supplies native -account storage and validation, the Typewriter EIP-712 domain, scheduler and -force-inclusion protocol, and the external execution entry points. The app owns -only its `State`, app mutation enum, and the `dispatch` callback. +The contract inherits from `typewriter/Typewriter.sol`, which supplies accounts +and authorization, the scheduler, and force inclusion. The app owns only its +`state`, its `Mutation` enum, its mutation libraries, and the `dispatch` +callback. ```solidity import {Typewriter, UnknownMutation} from "typewriter/Typewriter.sol"; @@ -428,43 +426,34 @@ contract Token is Typewriter { } ``` -**`state`.** A single non-public storage variable wraps the State struct. Underlying storage slots are read directly so Solidity visibility does not affect framework reads. +The app contract: -**`Mutation` enum.** Designates the valid mutations for the contract. +- inherits only `Typewriter` and isn't abstract; +- takes exactly `constructor(address scheduler)` and passes `scheduler` to `Typewriter(scheduler)`, so typewriter can deploy any app; the body may do more; +- keeps all its storage in the non-public `state` variable (`constant`s are allowed); +- declares `enum Mutation`, with one mutation library per member, as described in Mutations above; +- adds no `public` or `external` functions; +- calls only `internal` library functions. -**`SCHEDULER`.** The privileged address for submitting mutations. Declared in `Typewriter` as `address internal immutable` and assigned by the `Typewriter` constructor. Every app has the same constructor, `constructor(address scheduler)`, and passes `scheduler` to `Typewriter(scheduler)`; it may do more in the body, but takes no other arguments, so typewriter can deploy any app. +Typewriter checks these rules at startup and names the one that's broken. -**`DOMAIN_SEPARATOR`.** Constructed by the base contract from the fixed -Typewriter name/version, `block.chainid`, and `address(this)`. - -**`FORCE_INCLUSION_DELAY`.** The number of seconds that must elapse after a mutation is enqueued before any caller may `forceExecute` it. Declared in `Typewriter` as `uint256 internal immutable`, initialized to `5 minutes`. An app that needs a different delay reassigns it in its constructor: +**`FORCE_INCLUSION_DELAY`.** The number of seconds that must elapse after a mutation is enqueued before any caller may `forceExecute` it. It defaults to `5 minutes`; an app that needs a different delay reassigns it in its constructor: ```solidity -constructor(address _scheduler) { - SCHEDULER = _scheduler; +constructor(address scheduler) Typewriter(scheduler) { FORCE_INCLUSION_DELAY = 10 minutes; } ``` -**Inherited external ABI.** `execute`, `enqueue`, and `forceExecute` are implemented by `Typewriter` (no app code), but they define the contract's external surface that the runtime and clients depend on: +**Force inclusion.** `Typewriter` implements `enqueue` and `forceExecute`, which let users bypass the server (see [Force inclusion](#force-inclusion)): ```solidity -// Scheduler-gated settlement. `forceExecuteIndexes` settles queued -// force-included mutations alongside the batches. -function execute(Batch[] calldata batches, uint256[] calldata forceExecuteIndexes) external; - -// User force-inclusion entry point. Pushes a `QueuedMutation` and emits -// `ForceInclusionQueued`. +// Queues a mutation onchain and emits `ForceInclusionQueued`. function enqueue(uint8 mutation, bytes calldata mutationData, bytes calldata authorizationData) external returns (uint256); -// Un-gated escape hatch, callable by anyone once `FORCE_INCLUSION_DELAY` seconds -// have elapsed since the entry was enqueued. +// Callable by anyone once `FORCE_INCLUSION_DELAY` seconds have elapsed since the entry was enqueued. function forceExecute(uint256 index) external; -``` -`enqueue` emits `ForceInclusionQueued`, which clients and watchers decode to discover queued mutations: - -```solidity event ForceInclusionQueued( uint256 index, uint8 mutation, @@ -481,20 +470,11 @@ function dispatch(uint8 mutation, bytes memory mutationData, bytes32 accountID) internal override; ``` -For app mutations, Typewriter first validates the account, credential, -expiration, permission, and nonce. It verifies the original `mutationData` -through the fixed authorization envelope, consumes the nonce, and calls -`dispatch` with the authenticated account ID. The callback branches on the app -mutation ID and ABI-decodes the mutation struct. Built-in account mutations are -handled entirely by the base contract. - -Typewriter doesn't read `dispatch`; it reads each mutation's params from its -`XMutation` library. The app contract must inherit only -`Typewriter` and declare `enum Mutation` inside itself. It can't add `public` -or `external` functions or variables, and it keeps all its storage in `state` -(`constant`s are allowed). Library functions must be `internal`: typewriter -executes only the app contract, so it rejects an app that links external -libraries. +For app mutations, Typewriter first [verifies the authorization](#authorization) +and consumes the nonce, then calls `dispatch` with the authenticated account ID. +`dispatch` branches on the mutation ID, ABI-decodes `mutationData` into the +mutation's struct, and calls its library's `execute`. Built-in account +mutations are handled entirely by the base contract. #### Determinism @@ -504,7 +484,7 @@ Opcodes whose values can't be reproduced offchain produce divergence between off - `block.difficulty` / `block.prevrandao` (`PREVRANDAO`) — post-merge randomness is set by the block proposer. - `blockhash(n)` (`BLOCKHASH`) for blocks the server hasn't seen. - `blobhash(i)` (`BLOBHASH`) and `block.blobbasefee` (`BLOBBASEFEE`) — blob context belongs to the submitting transaction, not the mutation. -- `gasleft()` (`GAS`) — gas accounting in revm does not match the live EVM, so any branch on remaining gas can diverge. +- `gasleft()` (`GAS`) — the server's gas accounting doesn't match the chain's, so any branch on remaining gas can diverge. `block.number` and `block.timestamp` are approximations — not guaranteed to match the block a mutation settles in. The server executes accepted mutations against the last known block's values, so what a mutation sees lags the block of inclusion by at least one. They're fine for coarse-grained checks (expiries, epoch windows) but unsafe for tight windows, sub-block timing, or logic that branches on the exact block of inclusion. @@ -526,7 +506,7 @@ function createTypewriter( ): Promise; ``` -Starts the runtime and resolves to the `Typewriter` handle (see the [Server runtime](#server-runtime) example for a full call). On startup it connects to the chain and database, runs migrations, and hydrates local revm state from persisted slot writes; from there it accepts mutations and submits them onchain. +Starts the runtime and resolves to the `Typewriter` handle once it is ready to accept mutations (see the [Server runtime](#server-runtime) example for a full call). **`TypewriterConfig`.** Required fields, except in [dev mode](#local-development) (`dev: true`), where typewriter fills in all but `database`: @@ -538,7 +518,7 @@ Starts the runtime and resolves to the `Typewriter` handle (see the [Server runt Optional runtime controls: `blockPollingIntervalMs` (default `200`), `confirmations` (`{ finalizedBlockDepth? }`, default `5`), and `sequencing` (see [Sequencing](#sequencing); defaults to FIFO). -The Solidity entrypoint must be importable by Bun. At startup, typewriter runs `forge build`, reads the compiled ABI/storage layout/AST, and derives app mutation IDs, params, and typed storage from the contract. +The Solidity entrypoint must be importable by Bun and belong to a Foundry project, with `forge` on the `PATH`. At startup, typewriter builds the project and derives mutation IDs, params, and typed storage from the contract. **Logging.** Two environment variables control runtime logs: @@ -614,10 +594,9 @@ const accepted = await typewriter.execute({ typewriter.state: StorageView; ``` -`typewriter.state` resolves the top-level Solidity `state` variable by one proxy -layer. It reads the runtime's local revm-backed storage mirror, so accepted state -is visible before onchain inclusion. Leaf accesses are synchronous and return -values. +`typewriter.state` reads the app's `state` variable as the server has accepted +it, so a mutation's effects are visible as soon as it is accepted, before +onchain inclusion. ```ts const balance = typewriter.state.balances[accountID]; @@ -627,9 +606,8 @@ const balance = typewriter.state.balances[accountID]; #### `typewriter.accounts` -`typewriter.accounts` resolves the base contract's top-level native account -mapping by one proxy layer. Apps can inspect learned account keys, credentials, -and nonce lanes without placing account state inside their own `State` struct. +`typewriter.accounts` reads the base contract's `accounts` mapping, so apps can +inspect account credentials and nonce lanes. ```ts const account = typewriter.accounts[accountID]; @@ -661,7 +639,7 @@ Every mutation table starts with the same **lifecycle columns**: Then two groups of payload columns: -- **Param columns** — one per ABI parameter in the mutation struct decoded by `dispatch`, named after the parameter (an unnamed parameter becomes `arg`). +- **Param columns** — one per field of the mutation's struct, named after the field. - **Authorization columns** — fixed `authorization_account_id`, `authorization_credential_id`, `authorization_nonce`, `authorization_expiration`, and `authorization_signature` columns. From 19fc13e20980901856c970fc35152d3be17ee8e0 Mon Sep 17 00:00:00 2001 From: kyscott18 <43524469+kyscott18@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:19:59 +0000 Subject: [PATCH 07/10] Revert "docs: keep the typewriter README to what app developers need" This reverts commit 13340c2e4056a66bbe288fa92aedb68dec3ff9b7. --- README.md | 187 +++++++++++++++++++--------------- packages/typewriter/README.md | 122 +++++++++++++--------- 2 files changed, 178 insertions(+), 131 deletions(-) diff --git a/README.md b/README.md index 93d0a435..5f9bc782 100644 --- a/README.md +++ b/README.md @@ -35,17 +35,15 @@ struct State { Mutations are app-defined state transitions requested by a user and executed onchain. Updates to application state are done with mutations. ```solidity -library TransferMutation { - struct Transfer { - bytes32 to; - uint256 amount; - } +struct Transfer { + bytes32 to; + uint256 amount; +} - function execute(State storage state, Transfer memory transfer, bytes32 accountID) internal { - state.balances[accountID] -= transfer.amount; - unchecked { - state.balances[transfer.to] += transfer.amount; - } +function executeTransfer(State storage state, Transfer memory transfer, bytes32 accountID) { + state.balances[accountID] -= transfer.amount; + unchecked { + state.balances[transfer.to] += transfer.amount; } } ``` @@ -62,17 +60,18 @@ Accounts use two-dimensional nonces: a lane and a sequence number within that la ### Server runtime -A mutation is the framework equivalent of a transaction, but unlike a transaction it is not submitted directly onchain. Instead mutations are submitted to an application server where they can be reordered and accepted quickly, then eventually made durable with onchain execution. Users sign mutations, not transactions; in normal operation the server is the party that submits transactions to the chain. The server executes each mutation against its own copy of the contract's state, and once it knows the mutation will succeed onchain it responds `accepted` — usually within milliseconds, before a block is produced. +A mutation is the framework equivalent of a transaction, but unlike a transaction it is not submitted directly onchain. Instead mutations are submitted to an application server where they can be reordered and accepted quickly, then eventually made durable with onchain execution. Users sign mutations, not transactions; in normal operation the server is the party that submits transactions to the chain. Each mutation is executed locally in an embedded EVM (revm) against the server's copy of contract state, and once the server knows the mutation will succeed onchain it responds `accepted` — usually within milliseconds, before a block is produced. The lifecycle of a mutation is as follows: - **received**. The server has received the mutation. - **accepted**. The server has ordered and executed the mutation locally. - **included**. The server submitted the mutation onchain and it has been included in a block. -- **finalized**. The block that contains the mutation is `confirmations.finalizedBlockDepth` blocks deep. +- **safe**. The block that contains the mutation has been marked "safe" by consensus. (See JSON-RPC "safe" tag). +- **finalized**. The block that contains the mutation has been marked "finalized" by consensus. (See JSON-RPC "finalized" tag). `createTypewriter` starts the runtime and returns a handle for submitting mutations, reading state, and subscribing to events. -`createTypewriter` takes a Solidity entrypoint and runtime config. `TypewriterConfig` requires `address`, `account`, `chainId`, `rpcUrl`, and `database`, or just `database` in [dev mode](#local-development). Contract metadata (`storageLayout` and app mutations) is derived from the Solidity entrypoint; native account mutations are added automatically. Optional runtime controls are `blockPollingIntervalMs`, `confirmations`, and `sequencing`. +`createTypewriter` takes a Solidity entrypoint and runtime config. `TypewriterConfig` requires `address`, `account`, `chainId`, `rpcUrl`, and `database`, or just `database` in [dev mode](#local-development). Contract metadata (`storageLayout` and app mutations) is derived from the Solidity entrypoint; native account mutations are added automatically. Optional runtime controls are `blockPollingIntervalMs`, `confirmations`, `onFatalError`, and `sequencing`. ```ts import { createTypewriter } from "typewriter"; @@ -86,6 +85,7 @@ const typewriter = await createTypewriter(Token, { rpcUrl, database: { url: databaseUrl }, confirmations: { + safeBlockDepth: 1, finalizedBlockDepth: 5, }, blockPollingIntervalMs: 200, @@ -181,9 +181,11 @@ anvil --network monad --block-time 0.3 A mutation can be included in a block and later removed by a [chain reorganization](https://www.alchemy.com/overviews/what-is-a-reorg). By definition, this only affects mutations that have not reached the `finalized` state. -When a reorg is detected, mutations may move backwards in the mutation lifecycle (accepted => included => finalized). They are re-processed against the new canonical chain and re-emitted with their updated lifecycle status. +Reorgs are detected on the server by requesting new blocks with JSON-RPC requests and reconciling them into the local view of the chain. The specific algorithm used to detect reorgs is inspired by [Ponder](https://github.com/ponder-sh/ponder), where it has been used in production for millions of hours, cumulatively. + +When a reorg is detected, mutations may move backwards in the mutation lifecycle (submitted => accepted => included => safe => finalized). They are re-processed against the new canonical chain and re-emitted with their updated lifecycle status. -> Coming soon: As a protection against exposing user-facing data that may be reorged, apps can choose to use later lifecycle stages, such as `"included"` or `"finalized"`, to serve read requests. +> Coming soon: As a protection against exposing user-facing data that may be reorged, apps can choose to use later lifecycle stages, such as `"included"`, `"safe"`, or `"finalized"`, to serve read requests. ### Force inclusion @@ -191,7 +193,7 @@ Users can bypass the server entirely and submit their mutations directly to the Force inclusion uses a two-stage, enqueue then execute, mechanism. First, a user registers their mutation onchain by calling the `enqueue()` function. -When the server sees an enqueued mutation, it executes it and settles it onchain like any other mutation. +When the server detects a force-included mutation, it reconciles the force included mutation with its local state view, then includes the mutation onchain. If the server is completely offline, the user may call `forceExecute()` on their queued mutation after the `FORCE_INCLUSION_DELAY`. @@ -203,13 +205,13 @@ This can happen when the execution depends on EVM environment opcodes whose valu When a server-submitted transaction reverts, "accepted" mutations may move backwards in the mutation lifecycle (to "received"). They are re-processed against the current canonical chain state and re-emitted with their updated lifecycle status. From the user's perspective, this is no different than a chain reorganization. -Certain EVM environment opcodes, such as `COINBASE` or `PREVRANDAO`, make divergence likely or even guaranteed. See [Determinism](#determinism) for the full list. +The server execution environment uses values from the last known block for EVM environment opcodes. Certain EVM environment opcodes, such as `COINBASE` or `PREVRANDAO`, can make divergence more likely or even guaranteed. For a full list of unsupported opcodes, see [contract requirements](#contract-requirements). > Coming soon: If onchain execution results in a different state transition than the server accepted, the transaction will revert instead of settling a mismatched result. ### Crash recovery -The server can be restarted at any time without losing accepted mutations: they are persisted to Postgres as they are accepted, and the server restores its state from them on startup. +The server can be restarted at any time without losing accepted state. Every mutation and raw slot write is persisted to Postgres as it happens. On startup, the runtime rehydrates its local execution environment from the persisted slot writes. Mutations that were accepted before the crash but had not yet reached `finalized` are reconciled with the underlying chain on restart. @@ -222,8 +224,9 @@ In order to be Typewriter-compliant, a smart contract must be written with Solid #### State All application state is contained in one `struct State` stored in a top-level -variable named `state`. Typewriter keeps its account registry separately, in an -`accounts` mapping inherited from the base contract. +variable named `state`. Typewriter stores its account registry separately in a +top-level `accounts` mapping inherited from the base contract. Compiler storage +layout gives the runtime typed proxies for both roots. ```solidity struct State { @@ -232,7 +235,7 @@ struct State { } ``` -> Value types, enums, structs, fixed and dynamic arrays, mappings, `bytes`, and `string` are supported, nested in any combination. Fixed-point numbers, function types, contract and interface types, user-defined value types, and `bytes`/`string`/enum mapping keys are not supported and fail loudly. `Object.keys` on a mapping lists the keys that accepted mutations have read or written. +> Value types, enums, structs, fixed and dynamic arrays, mappings, `bytes`, and `string` are supported, nested in any combination. Fixed-point numbers, function types, contract and interface types, user-defined value types, and `bytes`/`string`/enum mapping keys are not supported and fail loudly. The runtime stores the 64-byte keccak256 preimages from each accepted execution, so `Object.keys` on a mapping lists every key that an accepted mutation hashed. #### Mutations @@ -250,7 +253,8 @@ entrypoint file or a file it imports, with: permissions, expiration, nonce, and signature checks have already happened in the base contract. -`execute` returns nothing and must be `internal`. +`execute` returns nothing and must be `internal`: an external library function +runs in a separately deployed library, which typewriter can't execute offchain. ```solidity library AddMutation { @@ -361,7 +365,7 @@ address matching the stored address. For `P256`, it verifies `(r, s)` over `sha256(digest)`. For `WebAuthnP256`, it checks that the 43-byte, unpadded base64url encoding of `digest` appears in `clientDataJSON` at `challengeOffset`, then verifies `(r, s)` over `sha256(authData || sha256(clientDataJSON))`. -Both P-256 paths need the chain's P-256 precompile at `0x100`, which Monad provides. +Both P-256 paths use the precompile at `0x100` and require it to return `1`. **Verification.** For an existing account, the base contract performs these checks before executing the mutation: @@ -396,10 +400,10 @@ signature must be nonempty, and `accountID` must equal #### Contract structure -The contract inherits from `typewriter/Typewriter.sol`, which supplies accounts -and authorization, the scheduler, and force inclusion. The app owns only its -`state`, its `Mutation` enum, its mutation libraries, and the `dispatch` -callback. +The contract inherits from `typewriter/Typewriter.sol`, which supplies native +account storage and validation, the Typewriter EIP-712 domain, scheduler and +force-inclusion protocol, and the external execution entry points. The app owns +only its `State`, app mutation enum, and the `dispatch` callback. ```solidity import {Typewriter, UnknownMutation} from "typewriter/Typewriter.sol"; @@ -426,34 +430,43 @@ contract Token is Typewriter { } ``` -The app contract: +**`state`.** A single non-public storage variable wraps the State struct. Underlying storage slots are read directly so Solidity visibility does not affect framework reads. + +**`Mutation` enum.** Designates the valid mutations for the contract. -- inherits only `Typewriter` and isn't abstract; -- takes exactly `constructor(address scheduler)` and passes `scheduler` to `Typewriter(scheduler)`, so typewriter can deploy any app; the body may do more; -- keeps all its storage in the non-public `state` variable (`constant`s are allowed); -- declares `enum Mutation`, with one mutation library per member, as described in Mutations above; -- adds no `public` or `external` functions; -- calls only `internal` library functions. +**`SCHEDULER`.** The privileged address for submitting mutations. Declared in `Typewriter` as `address internal immutable` and assigned by the `Typewriter` constructor. Every app has the same constructor, `constructor(address scheduler)`, and passes `scheduler` to `Typewriter(scheduler)`; it may do more in the body, but takes no other arguments, so typewriter can deploy any app. -Typewriter checks these rules at startup and names the one that's broken. +**`DOMAIN_SEPARATOR`.** Constructed by the base contract from the fixed +Typewriter name/version, `block.chainid`, and `address(this)`. -**`FORCE_INCLUSION_DELAY`.** The number of seconds that must elapse after a mutation is enqueued before any caller may `forceExecute` it. It defaults to `5 minutes`; an app that needs a different delay reassigns it in its constructor: +**`FORCE_INCLUSION_DELAY`.** The number of seconds that must elapse after a mutation is enqueued before any caller may `forceExecute` it. Declared in `Typewriter` as `uint256 internal immutable`, initialized to `5 minutes`. An app that needs a different delay reassigns it in its constructor: ```solidity -constructor(address scheduler) Typewriter(scheduler) { +constructor(address _scheduler) { + SCHEDULER = _scheduler; FORCE_INCLUSION_DELAY = 10 minutes; } ``` -**Force inclusion.** `Typewriter` implements `enqueue` and `forceExecute`, which let users bypass the server (see [Force inclusion](#force-inclusion)): +**Inherited external ABI.** `execute`, `enqueue`, and `forceExecute` are implemented by `Typewriter` (no app code), but they define the contract's external surface that the runtime and clients depend on: ```solidity -// Queues a mutation onchain and emits `ForceInclusionQueued`. +// Scheduler-gated settlement. `forceExecuteIndexes` settles queued +// force-included mutations alongside the batches. +function execute(Batch[] calldata batches, uint256[] calldata forceExecuteIndexes) external; + +// User force-inclusion entry point. Pushes a `QueuedMutation` and emits +// `ForceInclusionQueued`. function enqueue(uint8 mutation, bytes calldata mutationData, bytes calldata authorizationData) external returns (uint256); -// Callable by anyone once `FORCE_INCLUSION_DELAY` seconds have elapsed since the entry was enqueued. +// Un-gated escape hatch, callable by anyone once `FORCE_INCLUSION_DELAY` seconds +// have elapsed since the entry was enqueued. function forceExecute(uint256 index) external; +``` + +`enqueue` emits `ForceInclusionQueued`, which clients and watchers decode to discover queued mutations: +```solidity event ForceInclusionQueued( uint256 index, uint8 mutation, @@ -470,11 +483,20 @@ function dispatch(uint8 mutation, bytes memory mutationData, bytes32 accountID) internal override; ``` -For app mutations, Typewriter first [verifies the authorization](#authorization) -and consumes the nonce, then calls `dispatch` with the authenticated account ID. -`dispatch` branches on the mutation ID, ABI-decodes `mutationData` into the -mutation's struct, and calls its library's `execute`. Built-in account -mutations are handled entirely by the base contract. +For app mutations, Typewriter first validates the account, credential, +expiration, permission, and nonce. It verifies the original `mutationData` +through the fixed authorization envelope, consumes the nonce, and calls +`dispatch` with the authenticated account ID. The callback branches on the app +mutation ID and ABI-decodes the mutation struct. Built-in account mutations are +handled entirely by the base contract. + +Typewriter doesn't read `dispatch`; it reads each mutation's params from its +`XMutation` library. The app contract must inherit only +`Typewriter` and declare `enum Mutation` inside itself. It can't add `public` +or `external` functions or variables, and it keeps all its storage in `state` +(`constant`s are allowed). Library functions must be `internal`: typewriter +executes only the app contract, so it rejects an app that links external +libraries. #### Determinism @@ -484,7 +506,7 @@ Opcodes whose values can't be reproduced offchain produce divergence between off - `block.difficulty` / `block.prevrandao` (`PREVRANDAO`) — post-merge randomness is set by the block proposer. - `blockhash(n)` (`BLOCKHASH`) for blocks the server hasn't seen. - `blobhash(i)` (`BLOBHASH`) and `block.blobbasefee` (`BLOBBASEFEE`) — blob context belongs to the submitting transaction, not the mutation. -- `gasleft()` (`GAS`) — the server's gas accounting doesn't match the chain's, so any branch on remaining gas can diverge. +- `gasleft()` (`GAS`) — gas accounting in revm does not match the live EVM, so any branch on remaining gas can diverge. `block.number` and `block.timestamp` are approximations — not guaranteed to match the block a mutation settles in. The server executes accepted mutations against the last known block's values, so what a mutation sees lags the block of inclusion by at least one. They're fine for coarse-grained checks (expiries, epoch windows) but unsafe for tight windows, sub-block timing, or logic that branches on the exact block of inclusion. @@ -506,7 +528,7 @@ function createTypewriter( ): Promise; ``` -Starts the runtime and resolves to the `Typewriter` handle once it is ready to accept mutations (see the [Server runtime](#server-runtime) example for a full call). +Starts the runtime and resolves to the `Typewriter` handle (see the [Server runtime](#server-runtime) example for a full call). On startup it connects to the chain and database, runs migrations, and hydrates local revm state from persisted slot writes; from there it accepts mutations and submits them onchain. **`TypewriterConfig`.** Required fields, except in [dev mode](#local-development) (`dev: true`), where typewriter fills in all but `database`: @@ -516,9 +538,9 @@ Starts the runtime and resolves to the `Typewriter` handle once it is ready to a - `rpcUrl` — `string | string[]`. - `database` — `{ url, maxConnections? }` (Postgres). -Optional runtime controls: `blockPollingIntervalMs` (default `200`), `confirmations` (`{ finalizedBlockDepth? }`, default `5`), and `sequencing` (see [Sequencing](#sequencing); defaults to FIFO). +Optional runtime controls: `blockPollingIntervalMs` (default `200`), `confirmations` (`{ safeBlockDepth?, finalizedBlockDepth? }`, defaults `1` / `5`), `onFatalError` (`(error) => void`; without it a fatal runtime error is rethrown), and `sequencing` (see [Sequencing](#sequencing); defaults to FIFO). -The Solidity entrypoint must be importable by Bun and belong to a Foundry project, with `forge` on the `PATH`. At startup, typewriter builds the project and derives mutation IDs, params, and typed storage from the contract. +The Solidity entrypoint must be importable by Bun. At startup, typewriter runs `forge build`, reads the compiled ABI/storage layout/AST, and derives app mutation IDs, params, and typed storage from the contract. **Logging.** Two environment variables control runtime logs: @@ -569,15 +591,13 @@ const submitted = authorizeMutation(mutation, signature); #### `typewriter.execute()` ```ts -typewriter.execute(input: { name, params, authorization }): Promise<{ executionIndex }>; +typewriter.execute(input: { name, params, authorization }): Promise<{ id }>; ``` Submits an authorized mutation. `name` is the exact Solidity `Mutation` enum member (or built-in mutation name), `params` matches its struct, and `authorization` has the fixed native shape. It resolves once accepted and -rejects if contract execution reverts. `executionIndex` (`bigint`) is the -mutation's position in the onchain execution order and the primary key of its -row in the mutation tables. +rejects if contract execution reverts. ```ts const accepted = await typewriter.execute({ @@ -585,7 +605,7 @@ const accepted = await typewriter.execute({ params, authorization, }); -// accepted.executionIndex +// accepted.id ``` #### `typewriter.state` @@ -594,9 +614,10 @@ const accepted = await typewriter.execute({ typewriter.state: StorageView; ``` -`typewriter.state` reads the app's `state` variable as the server has accepted -it, so a mutation's effects are visible as soon as it is accepted, before -onchain inclusion. +`typewriter.state` resolves the top-level Solidity `state` variable by one proxy +layer. It reads the runtime's local revm-backed storage mirror, so accepted state +is visible before onchain inclusion. Leaf accesses are synchronous and return +values. ```ts const balance = typewriter.state.balances[accountID]; @@ -606,8 +627,9 @@ const balance = typewriter.state.balances[accountID]; #### `typewriter.accounts` -`typewriter.accounts` reads the base contract's `accounts` mapping, so apps can -inspect account credentials and nonce lanes. +`typewriter.accounts` resolves the base contract's top-level native account +mapping by one proxy layer. Apps can inspect learned account keys, credentials, +and nonce lanes without placing account state inside their own `State` struct. ```ts const account = typewriter.accounts[accountID]; @@ -618,28 +640,32 @@ const sequence = await account.nonces[lane]; #### `typewriter.schema` ```ts -typewriter.schema: TypewriterMutationSchema; +typewriter.schema: TypewriterSchema; ``` The [Drizzle](https://orm.drizzle.team) tables the runtime generates and migrates for this deployment. typewriter owns these tables; apps read from them to build read models (decoded mutation history, per-account views, and so on). -One table per Solidity `Mutation` enum member, named `_mutations` with the runtime mutation name lowercased — `Mutation.Transfer` becomes `typewriter.schema.transfer_mutations`, `Mutation.MarketOrder` becomes `marketorder_mutations`. A row is written when a mutation is `accepted`, and its block columns are set when it is `included`; `received` and `rejected` mutations are not persisted here. A mutation is finalized once its `block_number` is `confirmations.finalizedBlockDepth` blocks behind the chain head. The tables live in the Postgres schema `typewriter__
`. +One table per Solidity `Mutation` enum member, named `_mutations` with the runtime mutation name lowercased — `Mutation.Transfer` becomes `typewriter.schema.transfer_mutations`, `Mutation.MarketOrder` becomes `marketorder_mutations`. A row is written when a mutation is `accepted` and updated as it advances; `received` and `rejected` mutations are not persisted here. Every mutation table starts with the same **lifecycle columns**: | Column | Type | Notes | | --- | --- | --- | -| `execution_index` | `bigint`, primary key | the mutation's position in the onchain execution order; matches the `typewriter.execute()` result and event `executionIndex` | -| `accepted_at` | `timestamp with time zone`, defaults to now | | -| `block_number` | `bigint` | inclusion block; null until included | -| `block_hash` | `char(66)` (hex) | null until included | -| `block_timestamp` | `bigint` | null until included | -| `transaction_index` | `integer` | null until included | -| `transaction_hash` | `char(66)` (hex) | the settlement transaction; null until included | +| `id` | `integer`, primary key | mutation id; matches the `typewriter.execute()` result and event `id` | +| `status` | `mutation_status` enum | one of `accepted`, `included`, `safe`, `finalized` | +| `executionIndex` | `numeric(78,0)` bigint | speculative onchain execution index; assigned and persisted at acceptance | +| `blockNumber` | `numeric(78,0)` bigint | inclusion block; null until included | +| `blockHash` | `char(66)` (hex) | null until included | +| `blockTimestamp` | `numeric(78,0)` bigint | null until included | +| `transactionHash` | `char(66)` (hex) | the settlement transaction; null until included | +| `acceptedAt` | `timestamp`, defaults to now | | +| `includedAt` | `timestamp` | null until included | +| `safeAt` | `timestamp` | null until safe | +| `finalizedAt` | `timestamp` | null until finalized | Then two groups of payload columns: -- **Param columns** — one per field of the mutation's struct, named after the field. +- **Param columns** — one per ABI parameter in the mutation struct decoded by `dispatch`, named after the parameter (an unnamed parameter becomes `arg`). - **Authorization columns** — fixed `authorization_account_id`, `authorization_credential_id`, `authorization_nonce`, `authorization_expiration`, and `authorization_signature` columns. @@ -671,7 +697,7 @@ import { desc } from "drizzle-orm"; const recent = await db .select() .from(typewriter.schema.transfer_mutations) - .orderBy(desc(typewriter.schema.transfer_mutations.execution_index)) + .orderBy(desc(typewriter.schema.transfer_mutations.id)) .limit(20); ``` @@ -683,28 +709,29 @@ typewriter.on(event, callback): () => void; Subscribes to runtime events; returns an unsubscribe function. There are three events, each with its own payload type. -**`"mutation"` → `MutationEvent`** — fires every time a mutation changes lifecycle status. It is a discriminated union on `status`. Every variant carries `name` (the mutation name), `params`, and `authorization` (including `authorization.signature`); the rest depends on `status`: +**`"mutation"` → `MutationEvent`** — fires every time a mutation changes lifecycle status. It is a discriminated union on `status`. Every variant carries `id` (number), `name` (the mutation name), `params`, and `authorization` (including `authorization.signature`); the rest depends on `status`: | `status` | Additional fields | | --- | --- | | `"received"` | — | -| `"enqueued"` | `isForceInclusion` (`true`), `queueIndex` | -| `"accepted"` | `isForceInclusion`, `executionIndex` | -| `"included"` / `"finalized"` | `isForceInclusion`, `executionIndex`, `blockNumber`, `blockHash`, `blockTimestamp`, `transactionHash`, `transactionIndex` | +| `"enqueued"` | — | +| `"accepted"` | `isForceInclusion` | +| `"included"` / `"safe"` / `"finalized"` | `isForceInclusion` | | `"rejected"` | `isForceInclusion`, `error` | -This status set is wider than the happy-path lifecycle in [Server runtime](#server-runtime): `enqueued` occurs for force-included mutations — those a user submits directly onchain via `enqueue()` (see [Force inclusion](#force-inclusion)) — once the runtime detects and reconciles them, and `rejected` (carrying the rejection `error`) when a mutation fails. `isForceInclusion` marks mutations that entered through the force-inclusion queue; those also carry their `queueIndex`. `executionIndex` and the block fields match the `execution_index`, `block_*` and `transaction_*` columns of the mutation's row. +This status set is wider than the happy-path lifecycle in [Server runtime](#server-runtime): `enqueued` occurs for force-included mutations — those a user submits directly onchain via `enqueue()` (see [Force inclusion](#force-inclusion)) — once the runtime detects and reconciles them, and `rejected` (carrying the rejection `error`) when a mutation fails. `isForceInclusion` marks mutations that entered through the force-inclusion queue. **`"batch"` → `BatchEvent`** — fires when a batch changes status; batch sequencing only. Fields: -- `status` — `"accepted" | "included" | "finalized"` +- `status` — `"accepted" | "included" | "safe" | "finalized"` - `id` — number - `position` — the batch's position in submission order -- `mutations` — the batch's `MutationEvent`s, with the same `status` as the batch +- `mutations` — the batch's `MutationEvent`s (`accepted` and later; never `enqueued`/`rejected`) +- `forceIncludedMutations?` — force-included mutations settled alongside the batch **`"block"` → `BlockEvent`** — fires when a submitted block reaches a confirmation depth. Common fields: -- `status` — `"included" | "finalized"` (block events never fire for `accepted`, which is pre-block) +- `status` — `"included" | "safe" | "finalized"` (block events never fire for `accepted`, which is pre-block) - `number` — block number (`bigint`) - `hash` — block hash - `timestamp` — block timestamp (`bigint`) @@ -715,11 +742,9 @@ The remaining fields depend on sequencing: - FIFO — `mutations`: the block's `MutationEvent`s. - batch — `batches`: the block's `BatchEvent`s (excluding `accepted`); and `forceIncludedMutations`. -With FIFO sequencing, `on("batch", ...)` is a type error. - ```ts const unsubscribe = typewriter.on("mutation", (mutation) => { - console.log(mutation.name, mutation.status); + console.log(mutation.id, mutation.status); }); ``` diff --git a/packages/typewriter/README.md b/packages/typewriter/README.md index 93d0a435..7e642236 100644 --- a/packages/typewriter/README.md +++ b/packages/typewriter/README.md @@ -35,17 +35,15 @@ struct State { Mutations are app-defined state transitions requested by a user and executed onchain. Updates to application state are done with mutations. ```solidity -library TransferMutation { - struct Transfer { - bytes32 to; - uint256 amount; - } +struct Transfer { + bytes32 to; + uint256 amount; +} - function execute(State storage state, Transfer memory transfer, bytes32 accountID) internal { - state.balances[accountID] -= transfer.amount; - unchecked { - state.balances[transfer.to] += transfer.amount; - } +function executeTransfer(State storage state, Transfer memory transfer, bytes32 accountID) { + state.balances[accountID] -= transfer.amount; + unchecked { + state.balances[transfer.to] += transfer.amount; } } ``` @@ -62,7 +60,7 @@ Accounts use two-dimensional nonces: a lane and a sequence number within that la ### Server runtime -A mutation is the framework equivalent of a transaction, but unlike a transaction it is not submitted directly onchain. Instead mutations are submitted to an application server where they can be reordered and accepted quickly, then eventually made durable with onchain execution. Users sign mutations, not transactions; in normal operation the server is the party that submits transactions to the chain. The server executes each mutation against its own copy of the contract's state, and once it knows the mutation will succeed onchain it responds `accepted` — usually within milliseconds, before a block is produced. +A mutation is the framework equivalent of a transaction, but unlike a transaction it is not submitted directly onchain. Instead mutations are submitted to an application server where they can be reordered and accepted quickly, then eventually made durable with onchain execution. Users sign mutations, not transactions; in normal operation the server is the party that submits transactions to the chain. Each mutation is executed locally in an embedded EVM (revm) against the server's copy of contract state, and once the server knows the mutation will succeed onchain it responds `accepted` — usually within milliseconds, before a block is produced. The lifecycle of a mutation is as follows: - **received**. The server has received the mutation. @@ -181,6 +179,8 @@ anvil --network monad --block-time 0.3 A mutation can be included in a block and later removed by a [chain reorganization](https://www.alchemy.com/overviews/what-is-a-reorg). By definition, this only affects mutations that have not reached the `finalized` state. +Reorgs are detected on the server by requesting new blocks with JSON-RPC requests and reconciling them into the local view of the chain. The specific algorithm used to detect reorgs is inspired by [Ponder](https://github.com/ponder-sh/ponder), where it has been used in production for millions of hours, cumulatively. + When a reorg is detected, mutations may move backwards in the mutation lifecycle (accepted => included => finalized). They are re-processed against the new canonical chain and re-emitted with their updated lifecycle status. > Coming soon: As a protection against exposing user-facing data that may be reorged, apps can choose to use later lifecycle stages, such as `"included"` or `"finalized"`, to serve read requests. @@ -191,7 +191,7 @@ Users can bypass the server entirely and submit their mutations directly to the Force inclusion uses a two-stage, enqueue then execute, mechanism. First, a user registers their mutation onchain by calling the `enqueue()` function. -When the server sees an enqueued mutation, it executes it and settles it onchain like any other mutation. +When the server detects a force-included mutation, it reconciles the force included mutation with its local state view, then includes the mutation onchain. If the server is completely offline, the user may call `forceExecute()` on their queued mutation after the `FORCE_INCLUSION_DELAY`. @@ -203,13 +203,13 @@ This can happen when the execution depends on EVM environment opcodes whose valu When a server-submitted transaction reverts, "accepted" mutations may move backwards in the mutation lifecycle (to "received"). They are re-processed against the current canonical chain state and re-emitted with their updated lifecycle status. From the user's perspective, this is no different than a chain reorganization. -Certain EVM environment opcodes, such as `COINBASE` or `PREVRANDAO`, make divergence likely or even guaranteed. See [Determinism](#determinism) for the full list. +The server execution environment uses values from the last known block for EVM environment opcodes. Certain EVM environment opcodes, such as `COINBASE` or `PREVRANDAO`, can make divergence more likely or even guaranteed. For a full list of unsupported opcodes, see [contract requirements](#contract-requirements). > Coming soon: If onchain execution results in a different state transition than the server accepted, the transaction will revert instead of settling a mismatched result. ### Crash recovery -The server can be restarted at any time without losing accepted mutations: they are persisted to Postgres as they are accepted, and the server restores its state from them on startup. +The server can be restarted at any time without losing accepted state. Every mutation and raw slot write is persisted to Postgres as it happens. On startup, the runtime rehydrates its local execution environment from the persisted slot writes. Mutations that were accepted before the crash but had not yet reached `finalized` are reconciled with the underlying chain on restart. @@ -222,8 +222,9 @@ In order to be Typewriter-compliant, a smart contract must be written with Solid #### State All application state is contained in one `struct State` stored in a top-level -variable named `state`. Typewriter keeps its account registry separately, in an -`accounts` mapping inherited from the base contract. +variable named `state`. Typewriter stores its account registry separately in a +top-level `accounts` mapping inherited from the base contract. Compiler storage +layout gives the runtime typed proxies for both roots. ```solidity struct State { @@ -232,7 +233,7 @@ struct State { } ``` -> Value types, enums, structs, fixed and dynamic arrays, mappings, `bytes`, and `string` are supported, nested in any combination. Fixed-point numbers, function types, contract and interface types, user-defined value types, and `bytes`/`string`/enum mapping keys are not supported and fail loudly. `Object.keys` on a mapping lists the keys that accepted mutations have read or written. +> Value types, enums, structs, fixed and dynamic arrays, mappings, `bytes`, and `string` are supported, nested in any combination. Fixed-point numbers, function types, contract and interface types, user-defined value types, and `bytes`/`string`/enum mapping keys are not supported and fail loudly. The runtime stores the 64-byte keccak256 preimages from each accepted execution, so `Object.keys` on a mapping lists every key that an accepted mutation hashed. #### Mutations @@ -250,7 +251,8 @@ entrypoint file or a file it imports, with: permissions, expiration, nonce, and signature checks have already happened in the base contract. -`execute` returns nothing and must be `internal`. +`execute` returns nothing and must be `internal`: an external library function +runs in a separately deployed library, which typewriter can't execute offchain. ```solidity library AddMutation { @@ -361,7 +363,7 @@ address matching the stored address. For `P256`, it verifies `(r, s)` over `sha256(digest)`. For `WebAuthnP256`, it checks that the 43-byte, unpadded base64url encoding of `digest` appears in `clientDataJSON` at `challengeOffset`, then verifies `(r, s)` over `sha256(authData || sha256(clientDataJSON))`. -Both P-256 paths need the chain's P-256 precompile at `0x100`, which Monad provides. +Both P-256 paths use the precompile at `0x100` and require it to return `1`. **Verification.** For an existing account, the base contract performs these checks before executing the mutation: @@ -396,10 +398,10 @@ signature must be nonempty, and `accountID` must equal #### Contract structure -The contract inherits from `typewriter/Typewriter.sol`, which supplies accounts -and authorization, the scheduler, and force inclusion. The app owns only its -`state`, its `Mutation` enum, its mutation libraries, and the `dispatch` -callback. +The contract inherits from `typewriter/Typewriter.sol`, which supplies native +account storage and validation, the Typewriter EIP-712 domain, scheduler and +force-inclusion protocol, and the external execution entry points. The app owns +only its `State`, app mutation enum, and the `dispatch` callback. ```solidity import {Typewriter, UnknownMutation} from "typewriter/Typewriter.sol"; @@ -426,34 +428,43 @@ contract Token is Typewriter { } ``` -The app contract: +**`state`.** A single non-public storage variable wraps the State struct. Underlying storage slots are read directly so Solidity visibility does not affect framework reads. -- inherits only `Typewriter` and isn't abstract; -- takes exactly `constructor(address scheduler)` and passes `scheduler` to `Typewriter(scheduler)`, so typewriter can deploy any app; the body may do more; -- keeps all its storage in the non-public `state` variable (`constant`s are allowed); -- declares `enum Mutation`, with one mutation library per member, as described in Mutations above; -- adds no `public` or `external` functions; -- calls only `internal` library functions. +**`Mutation` enum.** Designates the valid mutations for the contract. -Typewriter checks these rules at startup and names the one that's broken. +**`SCHEDULER`.** The privileged address for submitting mutations. Declared in `Typewriter` as `address internal immutable` and assigned by the `Typewriter` constructor. Every app has the same constructor, `constructor(address scheduler)`, and passes `scheduler` to `Typewriter(scheduler)`; it may do more in the body, but takes no other arguments, so typewriter can deploy any app. -**`FORCE_INCLUSION_DELAY`.** The number of seconds that must elapse after a mutation is enqueued before any caller may `forceExecute` it. It defaults to `5 minutes`; an app that needs a different delay reassigns it in its constructor: +**`DOMAIN_SEPARATOR`.** Constructed by the base contract from the fixed +Typewriter name/version, `block.chainid`, and `address(this)`. + +**`FORCE_INCLUSION_DELAY`.** The number of seconds that must elapse after a mutation is enqueued before any caller may `forceExecute` it. Declared in `Typewriter` as `uint256 internal immutable`, initialized to `5 minutes`. An app that needs a different delay reassigns it in its constructor: ```solidity -constructor(address scheduler) Typewriter(scheduler) { +constructor(address _scheduler) { + SCHEDULER = _scheduler; FORCE_INCLUSION_DELAY = 10 minutes; } ``` -**Force inclusion.** `Typewriter` implements `enqueue` and `forceExecute`, which let users bypass the server (see [Force inclusion](#force-inclusion)): +**Inherited external ABI.** `execute`, `enqueue`, and `forceExecute` are implemented by `Typewriter` (no app code), but they define the contract's external surface that the runtime and clients depend on: ```solidity -// Queues a mutation onchain and emits `ForceInclusionQueued`. +// Scheduler-gated settlement. `forceExecuteIndexes` settles queued +// force-included mutations alongside the batches. +function execute(Batch[] calldata batches, uint256[] calldata forceExecuteIndexes) external; + +// User force-inclusion entry point. Pushes a `QueuedMutation` and emits +// `ForceInclusionQueued`. function enqueue(uint8 mutation, bytes calldata mutationData, bytes calldata authorizationData) external returns (uint256); -// Callable by anyone once `FORCE_INCLUSION_DELAY` seconds have elapsed since the entry was enqueued. +// Un-gated escape hatch, callable by anyone once `FORCE_INCLUSION_DELAY` seconds +// have elapsed since the entry was enqueued. function forceExecute(uint256 index) external; +``` +`enqueue` emits `ForceInclusionQueued`, which clients and watchers decode to discover queued mutations: + +```solidity event ForceInclusionQueued( uint256 index, uint8 mutation, @@ -470,11 +481,20 @@ function dispatch(uint8 mutation, bytes memory mutationData, bytes32 accountID) internal override; ``` -For app mutations, Typewriter first [verifies the authorization](#authorization) -and consumes the nonce, then calls `dispatch` with the authenticated account ID. -`dispatch` branches on the mutation ID, ABI-decodes `mutationData` into the -mutation's struct, and calls its library's `execute`. Built-in account -mutations are handled entirely by the base contract. +For app mutations, Typewriter first validates the account, credential, +expiration, permission, and nonce. It verifies the original `mutationData` +through the fixed authorization envelope, consumes the nonce, and calls +`dispatch` with the authenticated account ID. The callback branches on the app +mutation ID and ABI-decodes the mutation struct. Built-in account mutations are +handled entirely by the base contract. + +Typewriter doesn't read `dispatch`; it reads each mutation's params from its +`XMutation` library. The app contract must inherit only +`Typewriter` and declare `enum Mutation` inside itself. It can't add `public` +or `external` functions or variables, and it keeps all its storage in `state` +(`constant`s are allowed). Library functions must be `internal`: typewriter +executes only the app contract, so it rejects an app that links external +libraries. #### Determinism @@ -484,7 +504,7 @@ Opcodes whose values can't be reproduced offchain produce divergence between off - `block.difficulty` / `block.prevrandao` (`PREVRANDAO`) — post-merge randomness is set by the block proposer. - `blockhash(n)` (`BLOCKHASH`) for blocks the server hasn't seen. - `blobhash(i)` (`BLOBHASH`) and `block.blobbasefee` (`BLOBBASEFEE`) — blob context belongs to the submitting transaction, not the mutation. -- `gasleft()` (`GAS`) — the server's gas accounting doesn't match the chain's, so any branch on remaining gas can diverge. +- `gasleft()` (`GAS`) — gas accounting in revm does not match the live EVM, so any branch on remaining gas can diverge. `block.number` and `block.timestamp` are approximations — not guaranteed to match the block a mutation settles in. The server executes accepted mutations against the last known block's values, so what a mutation sees lags the block of inclusion by at least one. They're fine for coarse-grained checks (expiries, epoch windows) but unsafe for tight windows, sub-block timing, or logic that branches on the exact block of inclusion. @@ -506,7 +526,7 @@ function createTypewriter( ): Promise; ``` -Starts the runtime and resolves to the `Typewriter` handle once it is ready to accept mutations (see the [Server runtime](#server-runtime) example for a full call). +Starts the runtime and resolves to the `Typewriter` handle (see the [Server runtime](#server-runtime) example for a full call). On startup it connects to the chain and database, runs migrations, and hydrates local revm state from persisted slot writes; from there it accepts mutations and submits them onchain. **`TypewriterConfig`.** Required fields, except in [dev mode](#local-development) (`dev: true`), where typewriter fills in all but `database`: @@ -518,7 +538,7 @@ Starts the runtime and resolves to the `Typewriter` handle once it is ready to a Optional runtime controls: `blockPollingIntervalMs` (default `200`), `confirmations` (`{ finalizedBlockDepth? }`, default `5`), and `sequencing` (see [Sequencing](#sequencing); defaults to FIFO). -The Solidity entrypoint must be importable by Bun and belong to a Foundry project, with `forge` on the `PATH`. At startup, typewriter builds the project and derives mutation IDs, params, and typed storage from the contract. +The Solidity entrypoint must be importable by Bun. At startup, typewriter runs `forge build`, reads the compiled ABI/storage layout/AST, and derives app mutation IDs, params, and typed storage from the contract. **Logging.** Two environment variables control runtime logs: @@ -594,9 +614,10 @@ const accepted = await typewriter.execute({ typewriter.state: StorageView; ``` -`typewriter.state` reads the app's `state` variable as the server has accepted -it, so a mutation's effects are visible as soon as it is accepted, before -onchain inclusion. +`typewriter.state` resolves the top-level Solidity `state` variable by one proxy +layer. It reads the runtime's local revm-backed storage mirror, so accepted state +is visible before onchain inclusion. Leaf accesses are synchronous and return +values. ```ts const balance = typewriter.state.balances[accountID]; @@ -606,8 +627,9 @@ const balance = typewriter.state.balances[accountID]; #### `typewriter.accounts` -`typewriter.accounts` reads the base contract's `accounts` mapping, so apps can -inspect account credentials and nonce lanes. +`typewriter.accounts` resolves the base contract's top-level native account +mapping by one proxy layer. Apps can inspect learned account keys, credentials, +and nonce lanes without placing account state inside their own `State` struct. ```ts const account = typewriter.accounts[accountID]; @@ -639,7 +661,7 @@ Every mutation table starts with the same **lifecycle columns**: Then two groups of payload columns: -- **Param columns** — one per field of the mutation's struct, named after the field. +- **Param columns** — one per ABI parameter in the mutation struct decoded by `dispatch`, named after the parameter (an unnamed parameter becomes `arg`). - **Authorization columns** — fixed `authorization_account_id`, `authorization_credential_id`, `authorization_nonce`, `authorization_expiration`, and `authorization_signature` columns. From d9a3fc59362d978403fd40ed8590031e0386f19b Mon Sep 17 00:00:00 2001 From: kyscott18 <43524469+kyscott18@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:20:13 +0000 Subject: [PATCH 08/10] docs: describe this PR's contract rules without implementation details Co-Authored-By: Claude Opus 5.5 --- README.md | 37 ++++++++++++++++------------------- packages/typewriter/README.md | 37 ++++++++++++++++------------------- 2 files changed, 34 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 5f9bc782..6a8596ac 100644 --- a/README.md +++ b/README.md @@ -241,20 +241,15 @@ struct State { Each `Mutation` member `X` needs exactly one library named `XMutation`, in the entrypoint file or a file it imports, with: -- **`struct X`**. Typewriter reads the mutation's params from this struct: field - names and order define the params extracted into the manifest. The app's - `dispatch` callback ABI-decodes `mutationData` into it. Fields can be any ABI - type: value types, `bytes`, `string`, arrays, and nested structs. Enums - become `uint8`, user-defined value types their underlying type, and contracts - and interfaces `address`. +- **`struct X`**, the mutation's params. Field names and order define the + params in the manifest. Fields can be any ABI type: value types, `bytes`, + `string`, arrays, and nested structs. Enums become `uint8`, user-defined + value types their underlying type, and contracts and interfaces `address`. - **`function execute(State storage state, X memory mutation, bytes32 accountID) internal`**, - where `State` is the type of the app's `state` variable. Applies the mutation - to app state using the authenticated `accountID`. Account lookup, - permissions, expiration, nonce, and signature checks have already happened in - the base contract. - -`execute` returns nothing and must be `internal`: an external library function -runs in a separately deployed library, which typewriter can't execute offchain. + with no return value, where `State` is the type of the app's `state` + variable. Applies the mutation to app state using the authenticated + `accountID`. Account lookup, permissions, expiration, nonce, and signature + checks have already happened in the base contract. ```solidity library AddMutation { @@ -490,13 +485,15 @@ through the fixed authorization envelope, consumes the nonce, and calls mutation ID and ABI-decodes the mutation struct. Built-in account mutations are handled entirely by the base contract. -Typewriter doesn't read `dispatch`; it reads each mutation's params from its -`XMutation` library. The app contract must inherit only -`Typewriter` and declare `enum Mutation` inside itself. It can't add `public` -or `external` functions or variables, and it keeps all its storage in `state` -(`constant`s are allowed). Library functions must be `internal`: typewriter -executes only the app contract, so it rejects an app that links external -libraries. +The app contract must: + +- inherit only `Typewriter`; +- declare `enum Mutation` inside itself; +- keep all its storage in `state` (`constant`s are allowed); +- add no `public` or `external` functions or variables; +- call only `internal` library functions. + +Typewriter checks these rules at startup and names the one that's broken. #### Determinism diff --git a/packages/typewriter/README.md b/packages/typewriter/README.md index 7e642236..1057bc0c 100644 --- a/packages/typewriter/README.md +++ b/packages/typewriter/README.md @@ -239,20 +239,15 @@ struct State { Each `Mutation` member `X` needs exactly one library named `XMutation`, in the entrypoint file or a file it imports, with: -- **`struct X`**. Typewriter reads the mutation's params from this struct: field - names and order define the params extracted into the manifest. The app's - `dispatch` callback ABI-decodes `mutationData` into it. Fields can be any ABI - type: value types, `bytes`, `string`, arrays, and nested structs. Enums - become `uint8`, user-defined value types their underlying type, and contracts - and interfaces `address`. +- **`struct X`**, the mutation's params. Field names and order define the + params in the manifest. Fields can be any ABI type: value types, `bytes`, + `string`, arrays, and nested structs. Enums become `uint8`, user-defined + value types their underlying type, and contracts and interfaces `address`. - **`function execute(State storage state, X memory mutation, bytes32 accountID) internal`**, - where `State` is the type of the app's `state` variable. Applies the mutation - to app state using the authenticated `accountID`. Account lookup, - permissions, expiration, nonce, and signature checks have already happened in - the base contract. - -`execute` returns nothing and must be `internal`: an external library function -runs in a separately deployed library, which typewriter can't execute offchain. + with no return value, where `State` is the type of the app's `state` + variable. Applies the mutation to app state using the authenticated + `accountID`. Account lookup, permissions, expiration, nonce, and signature + checks have already happened in the base contract. ```solidity library AddMutation { @@ -488,13 +483,15 @@ through the fixed authorization envelope, consumes the nonce, and calls mutation ID and ABI-decodes the mutation struct. Built-in account mutations are handled entirely by the base contract. -Typewriter doesn't read `dispatch`; it reads each mutation's params from its -`XMutation` library. The app contract must inherit only -`Typewriter` and declare `enum Mutation` inside itself. It can't add `public` -or `external` functions or variables, and it keeps all its storage in `state` -(`constant`s are allowed). Library functions must be `internal`: typewriter -executes only the app contract, so it rejects an app that links external -libraries. +The app contract must: + +- inherit only `Typewriter`; +- declare `enum Mutation` inside itself; +- keep all its storage in `state` (`constant`s are allowed); +- add no `public` or `external` functions or variables; +- call only `internal` library functions. + +Typewriter checks these rules at startup and names the one that's broken. #### Determinism From 2326f42a8b005862ea3372d44a8ceff9363de9ba Mon Sep 17 00:00:00 2001 From: kyscott18 <43524469+kyscott18@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:23:06 +0000 Subject: [PATCH 09/10] docs: phrase the library rule in terms of implementing dispatch Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- packages/typewriter/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6a8596ac..05f8d865 100644 --- a/README.md +++ b/README.md @@ -491,7 +491,7 @@ The app contract must: - declare `enum Mutation` inside itself; - keep all its storage in `state` (`constant`s are allowed); - add no `public` or `external` functions or variables; -- call only `internal` library functions. +- implement `dispatch` by calling `internal` library functions only, such as each mutation library's `execute`. Typewriter checks these rules at startup and names the one that's broken. diff --git a/packages/typewriter/README.md b/packages/typewriter/README.md index 1057bc0c..7e446e57 100644 --- a/packages/typewriter/README.md +++ b/packages/typewriter/README.md @@ -489,7 +489,7 @@ The app contract must: - declare `enum Mutation` inside itself; - keep all its storage in `state` (`constant`s are allowed); - add no `public` or `external` functions or variables; -- call only `internal` library functions. +- implement `dispatch` by calling `internal` library functions only, such as each mutation library's `execute`. Typewriter checks these rules at startup and names the one that's broken. From 1368f67f3761ac88039a9cfd0b49049b60388b36 Mon Sep 17 00:00:00 2001 From: kyscott18 <43524469+kyscott18@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:24:10 +0000 Subject: [PATCH 10/10] docs: list the constructor rule with the other app contract rules Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + packages/typewriter/README.md | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index 05f8d865..56fe7f53 100644 --- a/README.md +++ b/README.md @@ -488,6 +488,7 @@ handled entirely by the base contract. The app contract must: - inherit only `Typewriter`; +- declare exactly `constructor(address scheduler)` and pass `scheduler` to `Typewriter(scheduler)`; the body may do more, but it takes no other arguments, so typewriter can deploy any app; - declare `enum Mutation` inside itself; - keep all its storage in `state` (`constant`s are allowed); - add no `public` or `external` functions or variables; diff --git a/packages/typewriter/README.md b/packages/typewriter/README.md index 7e446e57..816aaf6a 100644 --- a/packages/typewriter/README.md +++ b/packages/typewriter/README.md @@ -486,6 +486,7 @@ handled entirely by the base contract. The app contract must: - inherit only `Typewriter`; +- declare exactly `constructor(address scheduler)` and pass `scheduler` to `Typewriter(scheduler)`; the body may do more, but it takes no other arguments, so typewriter can deploy any app; - declare `enum Mutation` inside itself; - keep all its storage in `state` (`constant`s are allowed); - add no `public` or `external` functions or variables;