diff --git a/README.md b/README.md index 5bb5128c..56fe7f53 100644 --- a/README.md +++ b/README.md @@ -239,13 +239,17 @@ 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 - extracted into the manifest. -- **`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`**, 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`**, + 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 { @@ -481,6 +485,17 @@ 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. +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; +- 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. + #### 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/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/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..816aaf6a 100644 --- a/packages/typewriter/README.md +++ b/packages/typewriter/README.md @@ -237,13 +237,17 @@ 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 - extracted into the manifest. -- **`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`**, 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`**, + 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 { @@ -479,6 +483,17 @@ 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. +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; +- 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. + #### 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/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/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/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-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..a4cc6260 100644 --- a/packages/typewriter/src/sol-parse.test.ts +++ b/packages/typewriter/src/sol-parse.test.ts @@ -1,261 +1,671 @@ 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", - ], - }, - { - "id": 253, - "name": "CreateAccount", - "params": [ - "keyType:uint8", - "publicKey:bytes", - ], +/** + * 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;\nimport "Typewriter.sol";\nimport "State.sol";\n'; + const sources = Object.fromEntries( + Object.entries({ ...files, "App.sol": source }).map(([path, content]) => [ + path, + { content: `${header}${content}` }, + ]), + ); + const output = JSON.parse( + solc.compile( + JSON.stringify({ + language: "Solidity", + sources: { + "Typewriter.sol": { content: TYPEWRITER }, + "State.sol": { + content: + "// SPDX-License-Identifier: MIT\npragma solidity ^0.8.21;\nstruct State { uint256 total; }", + }, + ...sources, }, - { - "id": 254, - "name": "AddCredential", - "params": [ - "expiration:uint40", - "keyType:uint8", - "permissions:uint256", - "publicKey:bytes", - ], + settings: { + outputSelection: { + "*": { + "": ["ast"], + "*": ["abi", "storageLayout", "evm.bytecode.object"], + }, + }, }, - { - "id": 255, - "name": "RemoveCredential", - "params": [ - "credentialID:uint64", - ], - }, - ], - "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 as SolcOutput; +} + +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, files?: Record): string[] { + return Object.entries(parse(source, files).mutations) + .filter(([name]) => !Object.hasOwn(BUILTIN_MUTATIONS, name)) + .map( + ([name, { id, params }]) => + `${name}(${id}): ${params.map(({ name, type }) => `${type} ${name}`).join(", ")}`, + ); +} + +/** 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 { + State internal state; + constructor(address scheduler) Typewriter(scheduler) {} + function dispatch(uint8, bytes memory, bytes32) internal override {} + ${body} +}`; +} + +const COUNTER = ` +library AddMutation { + struct Add { + uint256 amount; } - `); -}); -test("parseSolidityMetadata extracts account-scoped Harness metadata", async () => { - const metadata = await parseSolidityMetadata(Harness); + function execute(State storage state, Add memory add, bytes32) internal { + state.total += add.amount; + } +} - expect({ - contractName: metadata.contractName, - storageLabels: metadata.storageLayout.storage.map((entry) => entry.label), - mutations: summarizeMutations(metadata.mutations), - }).toMatchInlineSnapshot(` +library SubMutation { + struct Sub { + uint256 amount; + } + + function execute(State storage state, Sub memory sub, bytes32) internal { + state.total -= sub.amount; + } +} + +contract Counter is Typewriter { + State internal state; + + enum Mutation { + Add, + Sub + } + + constructor(address scheduler) Typewriter(scheduler) {} + + function dispatch(uint8 mutation, bytes memory mutationData, bytes32 accountID) internal override { + if (mutation == uint8(Mutation.Add)) { + AddMutation.execute(state, abi.decode(mutationData, (AddMutation.Add)), accountID); + } else if (mutation == uint8(Mutation.Sub)) { + SubMutation.execute(state, abi.decode(mutationData, (SubMutation.Sub)), accountID); + } 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(` +struct Leg { + Side side; + Price price; +} +${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(` + [ + "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; } +${mutation("Place", "Inner leg; Inner[] legs;")} +${app("enum Mutation { Place }")}`).mutations; + expect(Place).toMatchInlineSnapshot(` { - "contractName": "Token", - "mutations": [ + "id": 0, + "params": [ { - "id": 0, - "name": "Transfer", - "params": [ - "to:bytes32", - "amount:uint256", + "components": [ + { + "name": "side", + "type": "uint8", + }, + { + "name": "price", + "type": "uint128", + }, ], + "name": "leg", + "type": "tuple", }, { - "id": 1, - "name": "Mint", - "params": [ - "amount:uint256", + "components": [ + { + "name": "side", + "type": "uint8", + }, + { + "name": "price", + "type": "uint128", + }, ], + "name": "legs", + "type": "tuple[]", }, - { - "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", ], } `); -}, 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, +}); + +test("parseSolidity finds mutation libraries in imported files", () => { + expect( + mutations( + ` +import "Add.sol"; +${mutation("Sub", "int256 amount;")} +${app("enum Mutation { Add, Sub }")}`, + { + "Add.sol": mutation("Add"), + // App.sol doesn't import it, so its library doesn't count. + "Other.sol": mutation("Other"), + }, ), - ).text(); + ).toMatchInlineSnapshot(` + [ + "Add(0): uint256 amount", + "Sub(1): int256 amount", + ] + `); +}); + +test("parseSolidity errors when the path was not compiled", () => { + expect(() => + parseSolidity(compile(COUNTER), "Missing.sol"), + ).toThrowErrorMatchingInlineSnapshot(`"Missing.sol was not compiled"`); +}); - expect(formatSolidityDeclaration(metadata)).toBe(declaration); -}, 20_000); +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", () => { + expect(() => + parse(`${app("", "First")}${app("", "Second")}`), + ).toThrowErrorMatchingInlineSnapshot( + `"App.sol must define one contract that inherits Typewriter, but defines First, Second"`, + ); +}); -test("parseSolidityMetadata includes the creation bytecode", async () => { - const metadata = await parseSolidityMetadata(Counter); +test("parseSolidity errors when the app inherits anything but Typewriter", () => { + expect(() => + parse(` +contract Ownable {} +contract App is Typewriter, Ownable { + State internal state; + constructor(address scheduler) Typewriter(scheduler) {} + function dispatch(uint8, bytes memory, bytes32) internal override {} +}`), + ).toThrowErrorMatchingInlineSnapshot( + `"App must inherit only Typewriter, but inherits Ownable, Typewriter"`, + ); +}); - expect(metadata.bytecode).toMatch(/^0x60[0-9a-f]+$/); +test("parseSolidity errors when the app contract is abstract", () => { + expect(() => + parse(`abstract ${app("enum Mutation { Add }")}`), + ).toThrowErrorMatchingInlineSnapshot(`"App must not be abstract"`); }); -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 the constructor doesn't take only 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)"`, + ); + 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("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 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 { + 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\`"`, ); }); -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 the app calls an external library", () => { + expect(() => + parse(` +library Math { + function double(uint256 x) external pure returns (uint256) { return 2 * x; } +} +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"`, ); }); -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 the app has no Mutation enum", () => { + expect(() => parse(app(""))).toThrowErrorMatchingInlineSnapshot( + `"App must declare \`enum Mutation\` inside the contract, with a member for each mutation"`, + ); + expect(() => + parse(` +enum Mutation { Add } +${mutation("Add")} +${app("")}`), + ).toThrowErrorMatchingInlineSnapshot( + `"App must declare \`enum Mutation\` inside the contract, with a member for each mutation"`, ); }); -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 mutation has a built-in name", () => { + expect(() => + parse(app("enum Mutation { CreateAccount }")), + ).toThrowErrorMatchingInlineSnapshot( + `"Mutation.CreateAccount is reserved by typewriter; rename it"`, + ); }); -test("parseSolidityMetadata rejects nested struct mutation params", async () => { - await expect(parseSolidityMetadata(NestedParam)).rejects.toThrow( - "Unsupported Solidity type", +test("parseSolidity errors when Mutation has more members than IDs", () => { + const members = Array.from({ length: 254 }, (_, i) => `M${i}`); + expect(() => + parse(app(`enum Mutation { ${members.join(", ")} }`)), + ).toThrowErrorMatchingInlineSnapshot( + `"Mutation has 254 members, but typewriter reserves IDs from 253; it can have at most 253"`, ); }); + +test("parseSolidity errors unless each mutation has one library", () => { + expect(() => + parse(app("enum Mutation { Add }")), + ).toThrowErrorMatchingInlineSnapshot( + `"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 allows other libraries, constants, and internal functions", () => { + expect( + mutations(` +${mutation("Add")} +${mutation("Sub")} +${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( + `"App must not add public or external functions or variables, but declares \`state\`"`, + ); +}); + +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 {}", + ), + ), + 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\`", + "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\`", + "returns": "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(` +${mutation("Call", "function() external fn;")} +${app("enum Mutation { Call }")}`), + ).toThrowErrorMatchingInlineSnapshot( + `"Call.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..e659d70b 100644 --- a/packages/typewriter/src/sol-parse.ts +++ b/packages/typewriter/src/sol-parse.ts @@ -1,829 +1,328 @@ -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[]; + sourceUnit: number; + visibility: string; + storageLocation: string; + parameters: { parameters: AstNode[] }; + returnParameters: { parameters: AstNode[] }; + kind: string; + mutability: string; + referencedDeclaration: number; + 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)}]`; + const nodes = new Map(); + for (const source of Object.values(output.sources)) { + for (const node of descendants(source.ast)) nodes.set(node.id, node); } - 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"; -} + const contract = findAppContract(unit, nodes, path); + const { abi, storageLayout, evm } = output.contracts[path]![contract.name]!; -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, - }); - } - return out; -} - -async function readArtifact(path: string): Promise { - const artifact = await Bun.file(path).json(); - return requireObject(artifact, `artifact ${path}`) as SolidityArtifact; -} - -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)) { - throw new ConfigError({ - message: `Could not find bytecode in artifact ${path}`, - }); - } - 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") { - 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(", ")})`, - }); - } -} - -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 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 stateVariable = contract.nodes.find( + (node) => node.nodeType === "VariableDeclaration" && node.name === "state", ); - if (matches.length !== 1) { + const stateStruct = + stateVariable && nodes.get(stateVariable.typeName.referencedDeclaration); + if (stateStruct?.nodeType !== "StructDefinition") { throw new ConfigError({ - message: `${contract.name ?? "entrypoint"} must define exactly one ${name} ${nodeType}`, + message: `${contract.name} must keep its state in a struct variable named \`state\``, }); } - return matches[0]!; -} - -function abiTypeFromTypeName(typeName: unknown): string { - const node = asAstNode(typeName, "typeName"); - if (node.nodeType === "ElementaryTypeName") { - if (typeof node.name !== "string") - throw new ConfigError({ - message: "Expected elementary type to have a 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}]`; - } - throw new ConfigError({ - message: `Unsupported Solidity type at ${node.src ?? "unknown source"}`, - }); -} - -function structToAbiParameters(struct: AstNode): readonly AbiParameter[] { - return asAstNodes(struct.members).map((member) => { - if (typeof member.name !== "string") { - throw new ConfigError({ - message: `Expected struct member at ${member.src ?? "unknown source"} to have a name`, - }); - } - 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) { - throw new ConfigError({ - message: `Storage layout must contain exactly one top-level ${label} variable`, - }); - } - const type = layout.types[roots[0]!.type] as StorageType | undefined; + for (const node of contract.nodes) { + const isVariable = node.nodeType === "VariableDeclaration"; if ( - label === "accounts" && - (type === undefined || - typeof type.key !== "string" || - typeof type.value !== "string") + isVariable && + node !== stateVariable && + node.mutability !== "constant" ) { throw new ConfigError({ - message: "Top-level accounts storage variable must be a mapping", + message: `${contract.name} must keep all its state in \`state\`, but declares \`${node.name}\``, }); } + const isEntrypoint = + node.visibility === "public" || node.visibility === "external"; if ( - label === "state" && - (type === undefined || !Array.isArray(type.members)) + isEntrypoint && + (isVariable || + (node.nodeType === "FunctionDefinition" && node.kind !== "constructor")) ) { throw new ConfigError({ - message: "Top-level state storage variable must be a struct", + message: `${contract.name} must not add public or external functions or variables, but declares \`${node.name || node.kind}\``, }); } } - return layout; -} - -function isIdentifier(node: unknown, name: string): boolean { - return isObject(node) && node.nodeType === "Identifier" && node.name === name; -} - -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 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; - } - return undefined; -} - -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"); -} - -function isMutationMember(node: unknown): boolean { - return ( - isObject(node) && - node.nodeType === "MemberAccess" && - isIdentifier(node.expression, "Mutation") && - typeof node.memberName === "string" - ); -} - -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 bytecode = `0x${evm.bytecode.object}`; + if (!Hex.validate(bytecode, { strict: true })) { + throw new ConfigError({ + message: `${contract.name} calls an external library function, which typewriter can't execute offchain; make the library's functions internal`, + }); } - 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 mutationEnum = contract.nodes.find( + (node) => node.nodeType === "EnumDefinition" && node.name === "Mutation", ); - 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)]; + if (mutationEnum === undefined) { + throw new ConfigError({ + message: `${contract.name} must declare \`enum Mutation\` inside the contract, with a member for each mutation`, + }); } - 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 - : 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", - ); - const body = asAstNode(dispatch.body, "dispatch body"); - const firstStatement = asAstNodes(body.statements)[0]; - if (firstStatement === undefined) + if (mutationEnum.members.length > FIRST_BUILTIN_MUTATION_ID) { throw new ConfigError({ - message: "Expected dispatch to contain mutation branches", + 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 branches = new Map( - flattenDispatchBranches(firstStatement).map((branch) => [ - branch.name, - branch.body, - ]), - ); + } - return enumMembers.map((member) => { - const branch = branches.get(member.enumName); - if (branch === undefined) { + const libraries = importedLibraries(unit, nodes); + const mutations: SolidityApp["mutations"] = {}; + for (const [id, { name }] of mutationEnum.members.entries()) { + if (Object.hasOwn(BUILTIN_MUTATIONS, name)) { throw new ConfigError({ - message: `Could not find dispatch branch for Mutation.${member.enumName}`, + message: `Mutation.${name} is reserved by typewriter; rename it`, }); } - const mutationStructIds = findDecodeStructIds(branch, "mutationData"); - if (mutationStructIds.length !== 1) { + const matches = libraries.filter( + (library) => library.name === `${name}Mutation`, + ); + if (matches.length !== 1) { throw new ConfigError({ - message: `Mutation.${member.enumName} must decode mutationData exactly once`, + message: `Mutation.${name} needs one library named ${name}Mutation in ${path} or its imports, but there are ${matches.length}`, }); } - const mutationStruct = params.declarations.get(mutationStructIds[0]!); - if (mutationStruct?.nodeType !== "StructDefinition") { + 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.returnParameters.parameters.length === 0 && + 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: `Mutation.${member.enumName} decode target is not a struct`, + message: `${library.name} must declare \`struct ${name}\` with the mutation's params and \`${signature}\``, }); } - 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, - }; + mutations[name] = { id, params: structParameters(struct, nodes) }; + } + for (const [name, { id, params }] of Object.entries(BUILTIN_MUTATIONS)) { + mutations[name] = { id, params }; } - return createTypewriterApp({ - config: baseConfig, - abi: metadata.abi, - storageLayout: metadata.storageLayout, - mutations, - devChain, - }); -} - -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 }, - ]), - ), + contractName: contract.name, + abi, + bytecode, + storageLayout, + mutations, }; } -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. - -declare const entrypoint: import("typewriter").TypewriterSolidityEntrypoint<${metadataLiteral}>; -export default entrypoint; -`; +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); } -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; +function findAppContract( + unit: AstNode, + nodes: Map, + path: string, +): AstNode { + const contracts = unit.nodes.filter( + (node) => + node.nodeType === "ContractDefinition" && + 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, but defines ${contracts.length === 0 ? "none" : contracts.map(({ name }) => name).join(", ")}`, + }); } -} - -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) { + const contract = contracts[0]!; + const bases = contract.linearizedBaseContracts.slice(1); + if (bases.length !== 1 || nodes.get(bases[0]!)!.name !== "Typewriter") { 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", + message: `${contract.name} must inherit only Typewriter, but inherits ${bases.map((id) => nodes.get(id)!.name).join(", ")}`, }); } - if (!(await fileExists(value))) { + if (contract.abstract) { throw new ConfigError({ - message: `Typewriter entrypoint file does not exist: ${displayPath(value)}`, + message: `${contract.name} must not be abstract`, }); } - return value; + return contract; +} + +/** The libraries defined in `unit` and the files it imports. */ +function importedLibraries( + unit: AstNode, + nodes: Map, +): AstNode[] { + const visited = new Set(); + const libraries: AstNode[] = []; + const visit = (unit: AstNode) => { + if (visited.has(unit.id)) return; + visited.add(unit.id); + 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 libraries; } -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 structParameters( + struct: AstNode, + nodes: Map, +): AbiParameter[] { + return struct.members.map((member) => + abiParameter(member.name, member.typeName, nodes, struct.name), + ); } -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`, - }); +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$/, "") }; + } + 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`, + }); +} - 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, - }), - ); +/** 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. - return { - contractName: contract.name, - abi, - bytecode: requireArtifactBytecode(artifact, artifactPath), - storageLayout: validateRootStorageLayout(artifact.storageLayout), - mutations: [...appMutations, ...builtinMutations], - }; +declare const entrypoint: import("typewriter").TypewriterSolidityEntrypoint<${metadata}>; +export default entrypoint; +`; } -/** 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; +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}]`; + } + 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}}`; + } + 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); - } - } -}