diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 888d42d..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "lib/forge-std"] - path = lib/forge-std - url = https://github.com/foundry-rs/forge-std diff --git a/README.md b/README.md index f7ca2ec..8a71158 100644 --- a/README.md +++ b/README.md @@ -1,145 +1,120 @@ -# M3tering Rollup +# M3tering Rollup 🐻‍❄️ -This repository contains the Ethereum settlement and runtime-readable state contract for the M3tering metering protocol. `Rollup` accepts SP1-proven state transitions, stores account and nonce snapshots in immutable contract code, and exposes current and historical six-byte records to other contracts. +Rollup stores proven meter accounts, nonces and public keys on Ethereum. Contracts can read the current state directly. The companion [prover](https://github.com/M3tering/Prover) validates signed meter actions with SP1 and submits sparse replacements for changed storage words. -The Solidity contract authenticates the transition through an SP1 verifier gateway. Meter-action rules, signature verification, and proofs of public keys held in an Ethereum keystore belong to the corresponding SP1 program; they are not independently implemented by this contract. +## State and units -**Deployment status:** `SP1_PROGRAM_VKEY` is marked TODO in source. Confirm the exact production program key, gateway deployment, public-value encoding, and a real end-to-end proof before deploying. The test verifier in this repository checks the contract/verifier boundary; it does not verify cryptographic proofs. +`words` is a public dynamic array of uint256 values. Four meters share each word. Meter `id` uses slot `id / 4` and lane `id % 4`; the lane starts at bit `64 * (id % 4)`. -## Repository layout +| Lane field | Bits | Meaning | +|---|---:|---| +| Account, low bits | 40 | Cumulative watt-hours | +| Nonce, high bits | 24 | Last accepted action nonce | -| Path | Purpose | -| --- | --- | -| `src/Rollup.sol` | State transition verification, snapshot creation, record getters | -| `src/interfaces/IRollup.sol` | Public contract ABI, errors, and event | -| `src/interfaces/ISP1Verifier.sol` | SP1 verifier gateway interface | -| `test/Rollup.t.sol` | Behavioral, negative-path, boundary, and fuzz tests | -| `test/Rollup.invariant.t.sol` | Randomized transition sequences checked against an independent history model | -| `foundry.toml`, `soldeer.lock` | Compiler, EVM target, dependency pins, and test configuration | -| `.github/workflows/test.yml` | Reproducible dependency install, formatting, build, and tests | +The account limit is 1,099,511,627,775 Wh and the nonce limit is 16,777,215. Slot IDs are uint32: 4,294,967,296 slots support IDs 0–17,179,869,183. Sparse array growth does not write zero-filled gaps. -## State lifecycle +Signed messages are eight bytes: uint32 big-endian nonce followed by uint32 big-endian **milliwatt-hours**. The prover verifies the exact signed bytes, processes consecutive nonces and aggregates accepted mWh per meter. Each commit adds `floor(total_mWh / 1000)` Wh. Fractional Wh are discarded at each commit. A nonce advances even when the Wh increment is zero. Bounds fail instead of wrapping. -Deployment creates two genesis snapshots, account first and nonce second. Each payload is `0x00`; each snapshot's deployed runtime code is consequently `0x0000`, because SSTORE2 adds a leading STOP byte. `chainLength` starts at zero, identifying genesis. It counts successful subsequent commits, so there are always `chainLength + 1` historical snapshot pairs. +Every full 32-byte public key stays in `publicKey(id)`. Only the current `M3ter.ownerOf(id)` may set or revoke it; zero revokes. An NFT transfer does not automatically rotate its key. There is no administrator bypass or nonce reset. -For `commitState(accountBlob, nonceBlob, proof)`: +## Updates and proof statement -1. Both payloads must have equal length. Each must contain at most 24,575 bytes. -2. The contract builds public values from the stored anchor, current snapshot code hashes, and proposed snapshot runtime bytes, then calls the configured verifier. -3. If verification succeeds, it deploys the account snapshot and then the nonce snapshot using `SSTORE2.write`. -4. It increments `chainLength` and emits `NewState` containing the anchor used to verify this transition. -5. It sets `anchorBlock` to `blockhash(block.number - 1)` for the next proof. +`commitState(bytes updates, bytes proof)` takes concatenated **36-byte records**, sorted by strictly increasing slot: -Anyone may submit a valid proof. A verifier rejection or deployment failure reverts the entire call, including both CREATE nonce effects, stored fields, and logs. There are no owner-only transition controls or upgrade functions in this contract. +``` +uint32 slot, big endian 4 bytes +uint256 new word, big endian 32 bytes +``` -## Anchoring the EVM state +There are no modes, deltas, block anchors or global sequence counters. Empty/malformed data, duplicate/descending slots and non-increasing words revert. The guest enforces signatures, per-lane bounds, consecutive nonces and preservation of untouched lanes. -`anchorBlock` is an Ethereum **block hash**, not a state root. The SP1 program must authenticate the block header against this hash, derive its state root, and validate keystore account/storage proofs against that root before accepting meter signatures. +Public values are exactly `keccak256(transcript)` as 32 bytes. The transcript has a 52-byte prefix and 100 bytes per changed word: -The constructor sets the first anchor to its parent block. Each successful commit verifies against the previously stored anchor and selects its own parent block as the next anchor. This fixes the external-state snapshot before the next proof is generated. +| Field | Bytes | Purpose | +|---|---:|---| +| Chain ID | 32 | Bind the destination chain | +| Rollup address | 20 | Bind the destination contract | +| Slot, repeated | 4 | Bind the meter positions | +| Old word, repeated | 32 | Authenticate starting state and reject stale proofs | +| New word, repeated | 32 | Authenticate the replacement | +| Used-key hash, repeated | 32 | Authenticate the signing keys | -For example, deployment in block 100 selects block 99. A commit in block 110 proves against block 99, then selects block 109 for the following proof. A key rotation in block 105 is not visible to that first commit. This contract provides a previously selected snapshot, not validation against the latest state at transaction execution time. +The used-key hash is keccak256 of four 32-byte entries: the stored key when that lane changes, zero otherwise. Old words and keys come from contract storage, not calldata. The fixed guest key identifies the accepted protocol. Record count is implicit in the fixed-length encoding. No MPT proofs, headers or historical state authentication are required. -Several commits in one block can select the same next anchor. A long idle period does not expire a stored anchor hash after 256 blocks: the hash is already in storage. The prover still needs historical header/state witnesses, which may require an archive-capable data provider. If signature revocation must take effect sooner, a different freshness policy is needed in both the contract and proving protocol. +A proof remains valid while its old words and used keys match. Disjoint-slot batches can coexist; batches sharing a slot conflict. A successful transition cannot replay against its new state. Rotating a used key invalidates a pending proof; rotating an unrelated lane's key does not. There is no time expiry or key-epoch history. Rotating back can restore a still-current proof's validity. -## Proof public values +Batch destination binding does not change the eight-byte meter signature format. Reusing keys/nonces across deployments can permit the same signatures to be used to construct a different batch proof. -The exact encoding is raw byte concatenation, **not** `abi.encode`: +## Commit execution and events -```text -anchorBlock 32 bytes -keccak256(previous account code) 32 bytes -keccak256(previous nonce code) 32 bytes -0x00 || accountBlob 1 + L bytes -0x00 || nonceBlob 1 + L bytes -``` +One loop decodes each record, reads old state and used keys, constructs its transcript record and writes the replacement word. Temporary memory is reused for key hashing. Array length grows once after the loop, if needed. The verifier is called through a `view` interface, so verification uses STATICCALL. Failure reverts every provisional write and the length change. A missing verifier fails closed. -The total length is `98 + 2*L`. Previous code hashes include the SSTORE2 STOP byte. The caller supplies payloads without that byte; the contract adds it to the statement and SSTORE2 adds it when deploying code. +The verifier can read provisional new state but cannot mutate state through its static call. It must verify the supplied guest key, public values and proof rather than depend on getters exposing old values. -The equal-length rule makes the final two fields unambiguous: after the first 96 bytes, split the remainder exactly in half and require both leading bytes to be zero. The prover and submission client must use this same framing. The zkVM must enforce it: unequal prover outputs can otherwise encode the same bytes as a different equal-length Solidity pair. If they trim trailing zero records independently, pad both outputs to a shared length **before producing the proof**. Padding after proving changes the public values and invalidates that proof. +After success, `StateCommitted(bytes updates)` emits the exact update payload. Indexers split it into 36-byte records and overwrite their corresponding words. Transaction/log order provides ordering; reorgs require rolling back indexed events. Logs do not provide historical EVM-accessible state. -The contract does not separately enforce record alignment, monotonic meter nonces, balance arithmetic, or signature authorization. The pinned SP1 program must enforce the intended semantic rules. Empty payloads and partial trailing records pass the Solidity shape checks if a proof is accepted. +## Contract interface -The current statement does not include the Rollup address, chain ID, or `chainLength`. Changed parent hashes or an updated anchor invalidate an old statement. However, a no-op proof can be reused when the anchor and both parent hashes remain unchanged. Identical deployments may also share statements. Applications needing unique transitions or deployment-specific proofs should add explicit domain/sequence binding in a coordinated protocol change. +| Function | Behavior | +|---|---| +| `commitState(updates, proof)` | Verify and apply sparse replacements | +| `setPublicKey(id, key)` | Owner-authorized key registration, rotation or revocation | +| `words(slot)` | Generated array getter; reverts outside the current length | +| `wordCount()` | Current array length | +| `publicKey(id)` | Full stored key; zero if absent/revoked | +| `account(id)` | uint40 Wh account; zero for absent state | +| `nonce(id)` | uint24 nonce; zero for absent state | -## Snapshot addresses +Storage slot 0 holds the word-array length; slot 1 is the key mapping base. Word `slot` lives at `keccak256(bytes32(0)) + slot`. Changing this layout or wire format requires a coordinated prover release. -Snapshots use direct CREATE; no CREATE3 helper proxy or stored pointer mapping is needed. A newly created contract begins with CREATE nonce 1. Genesis consumes nonces 1 and 2; each later transition consumes exactly two more. +`src/Rollup.sol` contains the implementation. `src/interfaces/IRollup.sol` declares the API, errors/events and M3ter owner interface. Functions are ordered external, public, internal, private; state-changing functions precede view functions, which precede pure functions within each visibility group. Generated public getters are declared with their storage variables. -For historical state index `s`: +## Build and deployment -```text -account CREATE nonce = 2*s + 1 -nonce CREATE nonce = 2*s + 2 -address = last20bytes(keccak256(RLP([address(rollup), CREATE nonce]))) -``` +Install Foundry 1.8.1, then: -`stateAddress(s, io)` implements this calculation using Solady `LibRLP`. `io == 0` selects account state; **every nonzero value** selects nonce state. After genesis and every successful commit, the Rollup account's actual nonce is `2*chainLength + 3`. +```sh +forge soldeer install +forge fmt --check +forge build --sizes +FOUNDRY_PROFILE=ci forge test -vvv +``` -This calculation depends on exactly two ordered CREATEs per successful transition and the two constructor CREATEs. Adding any other CREATE/CREATE2 in the Rollup execution context, switching to a proxy without equivalent initialization, or skipping a snapshot requires redesigning the address scheme. Failed transactions do not consume the sequence. Sending ETH to a future snapshot address alone does not prevent its creation. +The compiler is Solidity 0.8.37 with Cancun-compatible bytecode. Forge-std 1.16.2 is locked by Soldeer. The contract no longer needs Solady. -## Reading six-byte records +Before deployment, set the three TODO constants in `Rollup.sol`: `VERIFIER`, `SP1_PROGRAM_VKEY` and `M3ter`. Derive the program key from the final companion program ELF and validate a real proof with the selected verifier. The checked-in placeholders are not deployment addresses/keys. There is no constructor. Deploying this storage layout starts empty; importing an existing deployment's state/keys requires a separately specified migration. -Records are indexed over the **deployed runtime** `0x00 || payload`. Record `tokenId` consists of runtime bytes `[6*tokenId, 6*tokenId + 6)`, padded on the right with zeros when a deployed snapshot is shorter. +## Gas and batch size -| Record | Payload bytes used | Result | -| --- | --- | --- | -| Token 0 | First five bytes | Leading `0x00` followed by five payload bytes | -| Token 1 | Bytes 5 through 10 | Six payload bytes | -| Token 2 | Bytes 11 through 16 | Six payload bytes | +A K-slot payload uses `36*K` bytes before the ABI envelope and proof. At 512 slots this is 18,432 bytes, 512 bytes more than three-byte slot IDs. For slots below 2^24 the additional index byte is zero, adding 4 intrinsic calldata gas per slot and approximately 8 log-data gas per slot, plus modest transcript/memory overhead. Higher slot IDs can add 16 rather than 4 calldata gas for that byte. -SSTORE2's read functions omit its STOP byte. The getter therefore adds that byte back for token zero and uses payload offset `6*tokenId - 1` for subsequent tokens. Token zero has 40 usable bits; the others have 48. `bytes6` preserves byte order; use `uint48(value)` for the corresponding unsigned big-endian integer. +Actual local Anvil Osaka receipts, cold storage, four changed meters per word, mock verifier and 32-byte mock proof: -At the 24,575-byte payload limit, runtime code is 24,576 bytes and contains 4,096 records, indexed 0 through 4,095. Each account/nonce snapshot has this capacity independently. Reads beyond a deployed snapshot return zero for ordinary token IDs. Extreme indexes can revert on checked arithmetic overflow. Reading an undeployed future snapshot fails in SSTORE2; address prediction itself does not assert that a snapshot exists. An all-zero record is not an explicit existence flag. +| Slots | Reused words | Fresh words | +|---:|---:|---:| +| 128 | 1,943,587 | 4,152,398 | +| 512 | 7,690,146 | 16,465,357 | -```solidity -IRollup rollup = IRollup(rollupAddress); -uint48 currentAccount = uint48(rollup.account(tokenId)); -uint48 currentNonce = uint48(rollup.nonce(tokenId)); -bytes6 historicalAccount = rollup.state(stateIndex, 0, tokenId); -address currentNonceSnapshot = rollup.latestStateAddress(1); -``` +The 512-slot increase over three-byte IDs was 6,682 gas (about 13.05 per slot). Binary searches at a 16,777,216 gas cap found these boundaries; the next slot was sent and failed with out-of-gas: -## Events - -```solidity -event NewState( - address indexed from, - bytes32 indexed anchorBlock, - uint256 indexed chainLength, - bytes accountBlob, - bytes nonceBlob, - bytes proof -); -``` +| Fixture | Three-byte IDs | Four-byte IDs | +|---|---:|---:| +| Reused, ordinary values | 1,118 | 1,117 | +| Fresh, ordinary values | 521 | 521 | +| Reused, all-nonzero new words | 1,101 | 1,100 | +| Fresh, all-nonzero new words | 518 | 517 | -For ordinary commits, `from` is the submitter, `chainLength` is the newly created index, and the event anchor is the one verified by that commit. The stored anchor after the transaction is for the next proof. +These are mock-verifier ceilings, not production limits. Real proof verification, proof calldata, sparse IDs and chain rules affect capacity. The prover's conservative defaults remain **647 reused / 322 fresh** words under current pricing, or **454 reused / 90 fresh** under the specified Glamsterdam model, with both budgets at 15m, 1.5m fixed allowance, 12,000 per-word overhead and 20% headroom. The extra byte remains inside that overhead allowance. Mixed batches are priced using their actual old words. -Genesis intentionally uses a sentinel event: anchor `bytes32(0)`, index zero, both payloads `0x00`, and proof `0x00`. No genesis proof is verified. The stored anchor is nevertheless the constructor's parent-block hash. Indexers must handle index zero explicitly rather than interpreting its proof/anchor as a verified transition. +The forecast uses [EIP-8037](https://eips.ethereum.org/EIPS/eip-8037) and [EIP-8038](https://eips.ethereum.org/EIPS/eip-8038); it is not a measurement on a finalized fork client. Recalibrate against the deployed verifier and target chain before changing allowances. The [transaction cap](https://eips.ethereum.org/EIPS/eip-7825) and any separate state-gas budget must both be respected. -## Build and test - -The project uses Foundry v1.8.1, Solidity 0.8.37, Solady 0.1.26, and forge-std 1.16.2. Soldeer installs dependencies into `dependencies/`; the legacy `lib/forge-std` submodule is not used by this configuration. +Reproduce local receipts after `forge build`: ```sh -foundryup --install v1.8.1 -forge soldeer install -forge fmt --check -forge build --sizes -forge test -vv -FOUNDRY_PROFILE=ci forge test -vvv --gas-report +anvil --hardfork osaka --gas-limit 100000000 --port 18548 --silent +# In another terminal, from this repository: +python3 scripts/benchmark.py +WORST_WORD=1 python3 scripts/benchmark.py ``` -Commit `foundry.toml` and `soldeer.lock` together. Use `forge soldeer update` deliberately when changing versions; ordinary setup and CI use `install`. CI rejects lockfile/config changes caused by installation. - -The EVM code-generation/test target is pinned to Cancun, and the contract-size limit is explicitly 24,576 bytes. This gives a reproducible baseline; it does not simulate proposed Glamsterdam gas schedules. Test gas reports include the test verifier and test machinery and must not be presented as production proof-verification or transaction costs. - -The default profile runs each fuzz test 256 times and each invariant for 64 sequences of depth 32. CI uses 1,024 fuzz cases and 256 sequences of depth 64, with unexpected handler reverts treated as failures. Foundry prints failing inputs and retains counterexamples for diagnosis. - -## What the tests establish - -The tests check genesis/event conventions, accepted transitions from arbitrary submitters, exact proof-statement binding, invalid input/proof rollback, maximum and empty payloads, record decoding, all-nonzero `io` normalization, historical immutability, address calculation across RLP nonce boundaries, and recovery after a failed second creation. They also characterize no-op replay and old-anchor acceptance. - -Stateful tests maintain separate account/nonce histories and the expected anchor. After randomized valid and invalid submissions, they compare all historical snapshot code and record values against that model, together with `chainLength`, latest getters, and the actual CREATE nonce. Stateful submissions alternate between two EOA actors; unit tests also submit from a contract. The verifier double only accepts the statement prepared by the model. - -These checks do not establish the correctness of the SP1 program or gateway. Add a fixture using a real production program/proof, authenticated keystore state at a known anchor, and the exact deployment environment before treating this repository as an end-to-end protocol test. See [TESTING.md](TESTING.md) for the integration cases and maintenance policy. +The script resets only its hard-coded localhost node. JSON results are under `benchmarks/`. Tests cover authority, full-key persistence, rollback, static verification, replay/stale proofs, destination binding, framing, four-byte/max-slot boundaries, exact event bytes, Rust statement equality and stateful accounting. Mocks test the contract/prover interface; a real production proof remains part of deployment validation. diff --git a/TESTING.md b/TESTING.md deleted file mode 100644 index 2a59f47..0000000 --- a/TESTING.md +++ /dev/null @@ -1,34 +0,0 @@ -# Behavioral test specification - -These tests protect protocol behavior, not just a particular implementation of SSTORE2 addressing. When a protocol rule changes deliberately, update the specification, prover, contract, and relevant expected behavior together. - -## Executable checks - -| Requirement | Coverage | -| --- | --- | -| Genesis is index zero, two zero-payload snapshots, no verified genesis transition | Genesis storage, CREATE nonce, and sentinel event test | -| Only an accepted statement advances state | Strict verifier double; proof, account data, nonce data, parent account hash, parent nonce hash, anchor, and program-key mismatch cases | -| Submissions are permissionless | Arbitrary submitter test and randomized senders | -| The committed event describes the proven transition | Old anchor, sender, new index, payloads, and proof checked | -| Next proof is pinned to the previous successful commit's selected EVM snapshot | Block advancement test, stale-statement rejection, long-idle characterization | -| Blob framing is unambiguous and obeys the code-size limit | Equal lengths; mismatch/oversize rejection; exact maximum; empty payload acceptance | -| Records match runtime bytes, including genesis and partial records | Independent byte-loop oracle; payload fuzzing; token-zero and end-of-blob checks | -| Zero selects accounts and every nonzero selector selects nonces | Full uint256 `io` fuzzing and invariant reads with maximum selector | -| History cannot be overwritten by later transitions | Independent complete history model and 129-commit RLP boundary test | -| All successful commits consume exactly two CREATE nonces | Actual VM nonce and independently computed address checks | -| Failure cannot leave a partial snapshot or skip addresses | Proof/shape rejection checks; artificial second-CREATE collision and successful retry | -| Existing limitations remain visible | Undeployed future read, same-statement no-op replay, old stored anchor acceptance | - -`RollupHandler.advance` creates model-derived statements, random payloads, selection between two EOA actors, and block gaps. `reject` exercises three rejected submission classes. The invariant checks the complete modeled history after each action. Setup seeds a successful transition to avoid genesis-only vacuity. The invariant target is restricted to those two handler selectors; the fuzzer cannot directly reconfigure the verifier. - -The artificial collision uses a cheatcode to place code at the second predicted address. This is fault injection to test transaction atomicity; it does not demonstrate that an external attacker can deploy code at that address. - -## CI and gas policy - -Run formatting, locked dependency installation, build/size checks, behavioral fuzz tests, and stateful invariants on each pull request. The committed workflow implements these checks with a pinned Foundry version. - -Gas reports are informational. The previous workflow's plain `forge snapshot` generated numbers without comparing a committed baseline, so it was not a regression gate. If desired, add a dedicated gas suite with fixed payloads and clearly separate proof verification, snapshot writing, and reads; commit its baseline and run `forge snapshot --check` against that suite. Benchmark actual transaction gas separately, including calldata and the real verifier, on the intended chain/fork. - -Keep the mainnet code-size limit enabled in tests. Split oversized test harnesses rather than raising that limit and accidentally allowing oversized data contracts. Increase fuzz/invariant budgets periodically, and retain minimized failures as explicit regression tests. - -Invariant transaction origins and submitters are drawn from two fixed EOA actors. This prevents cheatcode impersonation from touching the Rollup or future snapshot addresses. Unit tests also submit from a deployed contract. A handler regression checks normalization of an input that names the Rollup itself. diff --git a/benchmarks/transaction-gas-worst.json b/benchmarks/transaction-gas-worst.json new file mode 100644 index 0000000..0672f9b --- /dev/null +++ b/benchmarks/transaction-gas-worst.json @@ -0,0 +1,58 @@ +{ + "environment": "Anvil 1.8.1 Osaka, 16,777,216 transaction cap, mock verifier and 32-byte mock proof", + "all_nonzero_new_words": true, + "measurements": [ + { + "fresh": false, + "maximum_words_mock_only": 1100, + "samples": [ + { + "words": 128, + "gas_used": 1974307, + "success": true + }, + { + "words": 512, + "gas_used": 7813026, + "success": true + }, + { + "words": 1100, + "gas_used": 16776292, + "success": true + }, + { + "words": 1101, + "gas_used": 16777216, + "success": false + } + ] + }, + { + "fresh": true, + "maximum_words_mock_only": 517, + "samples": [ + { + "words": 128, + "gas_used": 4183118, + "success": true + }, + { + "words": 512, + "gas_used": 16588237, + "success": true + }, + { + "words": 517, + "gas_used": 16750008, + "success": true + }, + { + "words": 518, + "gas_used": 16777216, + "success": false + } + ] + } + ] +} diff --git a/benchmarks/transaction-gas.json b/benchmarks/transaction-gas.json new file mode 100644 index 0000000..a9f74ab --- /dev/null +++ b/benchmarks/transaction-gas.json @@ -0,0 +1,58 @@ +{ + "environment": "Anvil 1.8.1 Osaka, 16,777,216 transaction cap, mock verifier and 32-byte mock proof", + "all_nonzero_new_words": false, + "measurements": [ + { + "fresh": false, + "maximum_words_mock_only": 1117, + "samples": [ + { + "words": 128, + "gas_used": 1943587, + "success": true + }, + { + "words": 512, + "gas_used": 7690146, + "success": true + }, + { + "words": 1117, + "gas_used": 16767657, + "success": true + }, + { + "words": 1118, + "gas_used": 16777216, + "success": false + } + ] + }, + { + "fresh": true, + "maximum_words_mock_only": 521, + "samples": [ + { + "words": 128, + "gas_used": 4152398, + "success": true + }, + { + "words": 512, + "gas_used": 16465357, + "success": true + }, + { + "words": 521, + "gas_used": 16754449, + "success": true + }, + { + "words": 522, + "gas_used": 16777216, + "success": false + } + ] + } + ] +} diff --git a/foundry.toml b/foundry.toml index 0d66fb0..b57056a 100644 --- a/foundry.toml +++ b/foundry.toml @@ -7,8 +7,8 @@ evm_version = "cancun" optimizer = true optimizer_runs = 1000 code_size_limit = 24576 +fs_permissions = [{ access = "read", path = "./test/fixtures" }] remappings = [ - "solady@0.1.26/=dependencies/solady-0.1.26/", "forge-std/=dependencies/forge-std-1.16.2/src/", ] @@ -34,4 +34,3 @@ remappings_location = "config" [dependencies] forge-std = "1.16.2" -solady = "0.1.26" diff --git a/lib/forge-std b/lib/forge-std deleted file mode 160000 index 77041d2..0000000 --- a/lib/forge-std +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 77041d2ce690e692d6e03cc812b57d1ddaa4d505 diff --git a/remix.config.json b/remix.config.json deleted file mode 100644 index ea9119f..0000000 --- a/remix.config.json +++ /dev/null @@ -1,135 +0,0 @@ -{ - "mcp": { - "version": "1.0.0", - "security": { - "allowedFileTypes": [ - "sol", - "js", - "ts", - "json", - "md", - "txt", - "toml", - "yaml", - "yml", - "sql", - "jsx", - "tsx", - "abi", - "css", - "html", - "go", - "py", - "java", - "rb", - "php", - "rs", - "cpp", - "c", - "h", - "hpp" - ], - "blockedPaths": [ - ".env", - ".git", - "node_modules", - ".ssh", - "private", - "secret" - ], - "allowedPaths": [], - "maxExecutionTime": 30000, - "excludeTools": [], - "permissions": { - "requirePermissions": true, - "defaultPermissions": [ - "*" - ] - }, - "rateLimit": { - "enabled": true, - "requestsPerMinute": 100, - "burstAllowance": 4 - }, - "fileWritePermissions": { - "mode": "ask", - "allowedFiles": [] - } - }, - "validation": { - "validateSchemas": true, - "validateTypes": true, - "validateRanges": true, - "validateFormats": true, - "strictMode": false, - "toolValidation": {}, - "fileOperations": { - "maxFileSize": 10485760, - "allowedExtensions": [ - "sol", - "js", - "ts", - "json", - "md", - "txt", - "toml", - "yaml", - "yml", - "sql", - "jsx", - "tsx", - "abi", - "css", - "html", - "go", - "py", - "java", - "rb", - "php", - "rs", - "cpp", - "c", - "h", - "hpp" - ], - "blockedPatterns": [ - "**/node_modules/**", - "**/.git/**" - ] - }, - "networkOperations": { - "allowedNetworks": [ - "sepolia", - "goerli", - "localhost", - "vm", - "mainnet" - ], - "warnOnMainnet": true, - "maxGasLimit": 15000000 - } - }, - "resources": { - "enableCache": true, - "cacheTTL": 300000, - "excludeResources": [], - "allowResources": [], - "accessPatterns": { - "allowedPatterns": [], - "blockedPatterns": [] - } - }, - "features": { - "compilation": true, - "deployment": true, - "debugging": true, - "analysis": true, - "testing": true, - "git": true - }, - "logging": { - "level": "info", - "console": false - } - } -} \ No newline at end of file diff --git a/scripts/benchmark.py b/scripts/benchmark.py new file mode 100644 index 0000000..a03e8aa --- /dev/null +++ b/scripts/benchmark.py @@ -0,0 +1,79 @@ +"""Local-only transaction gas benchmark. Start Anvil Osaka on localhost:18548. +Uses mock verifier; never connect this script to a public RPC. +""" +import json, urllib.request, time, os +from pathlib import Path +ROOT=Path(__file__).resolve().parents[1] +URL='http://127.0.0.1:18548' +def rpc(method,params): + data=json.dumps({'jsonrpc':'2.0','id':1,'method':method,'params':params}).encode() + response=json.load(urllib.request.urlopen(urllib.request.Request(URL,data,{'Content-Type':'application/json'}))) + if 'error' in response: raise RuntimeError(response['error']) + return response['result'] +def batch(items): + result=[] + for start in range(0,len(items),256): + entries=[{'jsonrpc':'2.0','id':i,'method':m,'params':p} for i,(m,p) in enumerate(items[start:start+256])] + data=json.dumps(entries).encode() + responses=json.load(urllib.request.urlopen(urllib.request.Request(URL,data,{'Content-Type':'application/json'}))) + for item in sorted(responses,key=lambda x:x['id']): + if 'error' in item: raise RuntimeError(item['error']) + result.append(item['result']) + return result +word=lambda n:n.to_bytes(32,'big') +hx=lambda b:'0x'+b.hex() +def hashbytes(b):return bytes.fromhex(rpc('web3_sha3',[hx(b)])[2:]) +rpc('anvil_reset',[]) +target='0x0000000000000000000000000000000000003333' +verifier='0x0000000000000000000000000000000000001111' +rpc('anvil_setCode',[target,json.loads((ROOT/'out/Rollup.sol/Rollup.json').read_text())['deployedBytecode']['object']]) +rpc('anvil_setCode',[verifier,json.loads((ROOT/'out/Rollup.t.sol/TestVerifier.json').read_text())['deployedBytecode']['object']]) +account=rpc('eth_accounts',[])[0] +chain=int(rpc('eth_chainId',[]),16) +base=int.from_bytes(hashbytes(word(0)),'big') +ceiling=1600 +slots=batch([('web3_sha3',[hx(word(m)+word(1))]) for m in range(ceiling*4)]) +batch([('anvil_setStorageAt',[target,slot,hx(word(7))]) for slot in slots]) +selector=hashbytes(b'commitState(bytes,bytes)')[:4] +keys_hash=hashbytes(word(7)*4) +old=sum((10000+(100<<40))<<(64*i) for i in range(4)) +worst = os.environ.get("WORST_WORD") == "1" +new=(1<<256)-1 if worst else sum((10001+(101<<40))<<(64*i) for i in range(4)) +def calldata(count,fresh): + payload=b''.join(i.to_bytes(4,'big')+word(new) for i in range(count)) + transcript=word(chain)+bytes.fromhex(target[2:])+b''.join(i.to_bytes(4,'big')+word(0 if fresh else old)+word(new)+keys_hash for i in range(count)) + proof=hashbytes(transcript) + padded=payload+b'\0'*((-len(payload))%32) + return hx(selector+word(64)+word(96+len(padded))+word(len(payload))+padded+word(len(proof))+proof) +def call(count,fresh): + return {'from':account,'to':target,'data':calldata(count,fresh),'gas':hex(16_777_216)} +results={'environment':'Anvil 1.8.1 Osaka, 16,777,216 transaction cap, mock verifier and 32-byte mock proof','all_nonzero_new_words':worst,'measurements':[]} +for fresh in [False,True]: + batch([('anvil_setStorageAt',[target,hx(word(base+i)),hx(word(0 if fresh else old))]) for i in range(ceiling)]) + rpc('anvil_setStorageAt',[target,hx(word(0)),hx(word(0 if fresh else ceiling))]) + low,high=0,ceiling + while low bytes32) public publicKey; + // TODO: replace with the target chain's audited SP1 verifier before deployment. + ISP1Verifier public constant VERIFIER = ISP1Verifier(address(0x1111)); + // TODO: derive from the final prover program ELF; this placeholder is not a valid deployment key. + bytes32 public constant SP1_PROGRAM_VKEY = bytes32(uint256(1)); + // TODO: replace with the M3ter NFT deployment on the target chain. + address public constant M3ter = address(0x2222); + uint256 public constant MAX_SLOT = type(uint32).max; - constructor() { - SSTORE2.write(INCIPIT); // Genesis account: CREATE nonce 1. - SSTORE2.write(INCIPIT); // Genesis nonce: CREATE nonce 2. - emit NewState(msg.sender, hex"", 0, INCIPIT, INCIPIT, INCIPIT); - anchorBlock = blockhash(block.number - 1); + /// @dev Records: uint32 slot (BE), uint256 new value (BE), strictly ascending. + /// Transcript: chainId(32), this(20), then slot(4), old(32), new(32), usedKeysHash(32). + /// Verification is STATICCALL; failure atomically rolls back provisional writes. + function commitState(bytes calldata updates, bytes calldata proof) external { + if (updates.length == 0 || updates.length % 36 != 0) revert InvalidEncoding(); + uint256 count = updates.length / 36; + bytes memory transcript = new bytes(52 + 100 * count); + uint256 baseSlot; + assembly ("memory-safe") { + mstore(add(transcript, 32), chainid()) + mstore(add(transcript, 64), shl(96, address())) + mstore(0, words.slot) + baseSlot := keccak256(0, 32) + } + uint256 previous; + for (uint256 i; i < count; ++i) { + uint256 slot; + uint256 value; + uint256 old; + assembly ("memory-safe") { + let record := add(updates.offset, mul(i, 36)) + slot := shr(224, calldataload(record)) + value := calldataload(add(record, 4)) + old := sload(add(baseSlot, slot)) + } + if ((i != 0 && slot <= previous) || value <= old) revert InvalidUpdate(); + previous = slot; + bytes32 keysHash = _keysHash(slot, old ^ value); + assembly ("memory-safe") { + let dest := add(add(transcript, 84), mul(i, 100)) + mstore(dest, shl(224, slot)) + mstore(add(dest, 4), old) + mstore(add(dest, 36), value) + mstore(add(dest, 68), keysHash) + sstore(add(baseSlot, slot), value) + } + } + if (previous >= words.length) { + assembly ("memory-safe") { sstore(words.slot, add(previous, 1)) } + } + // ISP1Verifier declares verifyProof view: compiler emits STATICCALL, with a code-existence check. + VERIFIER.verifyProof(SP1_PROGRAM_VKEY, abi.encodePacked(keccak256(transcript)), proof); + emit StateCommitted(updates); } - function commitState(bytes calldata accountBlob, bytes calldata nonceBlob, bytes calldata proof) external { - require(accountBlob.length == nonceBlob.length, InvalidBlobs()); - require(accountBlob.length <= 24575, OversizeBlobs()); - // verifies proofs via SP1 Groth16 verifier gateway; reverts here if proof is invalid - ISP1Verifier(SP1_GROTH16_GATEWAY) - .verifyProof( - SP1_PROGRAM_VKEY, // ToDo: set to actual SP1 program vKey - bytes.concat( - anchorBlock, // ethereum state commitment - stateAddress(chainLength, 0).codehash, // parent account-state commitment - stateAddress(chainLength, 1).codehash, // parent nonce-state commitment - INCIPIT, - accountBlob, // proposed account-state - INCIPIT, - nonceBlob // proposed nonce-state - ), - proof - ); - - chainLength++; - - // Keep this order: exactly two CREATEs per committed state, account first. - SSTORE2.write(accountBlob); // CREATE nonce = 2 * chainLength + 1. - SSTORE2.write(nonceBlob); // CREATE nonce = 2 * chainLength + 2. - emit NewState(msg.sender, anchorBlock, chainLength, accountBlob, nonceBlob, proof); - - anchorBlock = blockhash(block.number - 1); + function setPublicKey(uint64 id, bytes32 key) external { + if (id / 4 > MAX_SLOT) revert InvalidUpdate(); + if (IM3ter(M3ter).ownerOf(id) != msg.sender) revert Unauthorized(); + publicKey[id] = key; + emit KeyChanged(id, key); } - function account(uint256 tokenId) external view returns (bytes6) { - return state(chainLength, 0, tokenId); + function wordCount() external view returns (uint256) { + return words.length; } - function nonce(uint256 tokenId) external view returns (bytes6) { - return state(chainLength, 1, tokenId); + function account(uint64 id) external view returns (uint40) { + return uint40(_pair(id)); } - function latestStateAddress(uint256 io) external view returns (address) { - return stateAddress(chainLength, io); + function nonce(uint64 id) external view returns (uint24) { + return uint24(_pair(id) >> 40); } - function state(uint256 stateIndex, uint256 io, uint256 tokenId) public view returns (bytes6) { - address pointer = stateAddress(stateIndex, io); - if (tokenId == 0) return bytes6(bytes.concat(INCIPIT, SSTORE2.read(pointer, 0, QUOTA - 1))); - uint256 index = (tokenId * QUOTA) - 1; - return bytes6(SSTORE2.read(pointer, index, index + QUOTA)); + function _pair(uint64 id) private view returns (uint256) { + uint256 slot = id / 4; + return slot < words.length ? words[slot] >> (64 * (id % 4)) : 0; } - function stateAddress(uint256 stateIndex, uint256 io) public view returns (address) { - return LibRLP.computeAddress(address(this), stateIndex * 2 + 1 + (io == 0 ? 0 : 1)); + function _keysHash(uint256 slot, uint256 changed) private view returns (bytes32 hash) { + // Reuse temporary memory instead of allocating 128 bytes on every iteration. + // slot is decoded as uint32, so all four meter IDs fit safely in uint64. + assembly ("memory-safe") { + let first := mul(slot, 4) + let buffer := mload(0x40) + mstore(buffer, 0) + mstore(add(buffer, 32), 0) + mstore(add(buffer, 64), 0) + mstore(add(buffer, 96), 0) + mstore(32, publicKey.slot) + if and(changed, 0xffffffffffffffff) { + mstore(0, first) + mstore(buffer, sload(keccak256(0, 64))) + } + if and(shr(64, changed), 0xffffffffffffffff) { + mstore(0, add(first, 1)) + mstore(add(buffer, 32), sload(keccak256(0, 64))) + } + if and(shr(128, changed), 0xffffffffffffffff) { + mstore(0, add(first, 2)) + mstore(add(buffer, 64), sload(keccak256(0, 64))) + } + if shr(192, changed) { + mstore(0, add(first, 3)) + mstore(add(buffer, 96), sload(keccak256(0, 64))) + } + hash := keccak256(buffer, 128) + } } } diff --git a/src/interfaces/IRollup.sol b/src/interfaces/IRollup.sol index 4ad2352..ee07574 100644 --- a/src/interfaces/IRollup.sol +++ b/src/interfaces/IRollup.sol @@ -1,39 +1,23 @@ // SPDX-License-Identifier: MIT -// Compatible with OpenZeppelin Contracts ^5.0.0 pragma solidity ^0.8.37; -interface IRollup { - error InvalidBlobs(); - error OversizeBlobs(); - - event NewState( - address indexed from, - bytes32 indexed anchorBlock, - uint256 indexed chainLength, - bytes accountBlob, - bytes nonceBlob, - bytes proof - ); - - function commitState(bytes calldata accountBlob, bytes calldata nonceBlob, bytes calldata proof) external; - - function QUOTA() external view returns (uint256); - - function SP1_GROTH16_GATEWAY() external view returns (address); - - function SP1_PROGRAM_VKEY() external view returns (bytes32); - - function anchorBlock() external view returns (bytes32); - - function chainLength() external view returns (uint256); - - function account(uint256 tokenId) external view returns (bytes6); - - function nonce(uint256 tokenId) external view returns (bytes6); - - function latestStateAddress(uint256 io) external view returns (address); - - function state(uint256 stateIndex, uint256 io, uint256 tokenId) external view returns (bytes6); +interface IM3ter { + function ownerOf(uint256 id) external view returns (address); +} - function stateAddress(uint256 stateIndex, uint256 io) external view returns (address); +interface IRollup { + error InvalidEncoding(); + error InvalidUpdate(); + error Unauthorized(); + event KeyChanged(uint64 indexed id, bytes32 key); + event StateCommitted(bytes updates); + + function commitState(bytes calldata updates, bytes calldata proof) external; + function setPublicKey(uint64 id, bytes32 key) external; + + function words(uint256 slot) external view returns (uint256); + function wordCount() external view returns (uint256); + function publicKey(uint64 id) external view returns (bytes32); + function account(uint64 id) external view returns (uint40); + function nonce(uint64 id) external view returns (uint24); } diff --git a/test/Rollup.invariant.t.sol b/test/Rollup.invariant.t.sol deleted file mode 100644 index 5281f1a..0000000 --- a/test/Rollup.invariant.t.sol +++ /dev/null @@ -1,140 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.37; - -import {Test} from "forge-std/Test.sol"; -import {StdInvariant} from "forge-std/StdInvariant.sol"; -import {Rollup} from "../src/Rollup.sol"; -import {IRollup} from "../src/interfaces/IRollup.sol"; -import {RollupFixture, StatementVerifier} from "./Rollup.t.sol"; - -contract RollupHandler is Test { - Rollup public immutable rollup; - StatementVerifier internal immutable verifier; - bytes32 internal immutable key; - bytes[] internal accounts; - bytes[] internal nonces; - bytes32 public anchor; - uint256 public rejected; - bytes internal constant PROOF = hex"123456"; - - constructor(Rollup r, StatementVerifier v, bytes32 k, bytes32 genesisAnchor) { - rollup = r; - verifier = v; - key = k; - anchor = genesisAnchor; - accounts.push(hex"00"); - nonces.push(hex"00"); - } - - function count() public view returns (uint256) { - return accounts.length - 1; - } - - function blobs(uint256 i) external view returns (bytes memory, bytes memory) { - return (accounts[i], nonces[i]); - } - - function advance(bytes32 seed, uint8 size, uint8 jump, address sender) external { - uint256 length = uint256(size) % 97; - bytes memory a = new bytes(length); - bytes memory n = new bytes(length); - for (uint256 i; i < length; ++i) { - a[i] = seed[i % 32]; - n[i] = bytes1(~uint8(a[i])); - } - if (jump % 4 != 0) { - vm.roll(block.number + uint256(jump)); - vm.setBlockhash(block.number - 1, keccak256(abi.encode(seed, block.number))); - } - uint256 last = count(); - bytes memory input = bytes.concat( - anchor, - keccak256(bytes.concat(hex"00", accounts[last])), - keccak256(bytes.concat(hex"00", nonces[last])), - hex"00", - a, - hex"00", - n - ); - verifier.authorize(key, input, PROOF); - // Use real actor addresses, never impersonate Rollup or a predicted - // snapshot. Foundry may touch a prank sender's nonce, creating artificial - // CREATE collisions if arbitrary addresses include future snapshots. - sender = uint160(sender) % 2 == 0 ? address(0xa11ce) : address(0xb0b); - vm.prank(sender); - rollup.commitState(a, n, PROOF); - accounts.push(a); - nonces.push(n); - anchor = blockhash(block.number - 1); - } - - function reject(uint8 mode) external { - uint256 beforeCount = count(); - uint64 beforeNonce = vm.getNonce(address(rollup)); - if (mode % 3 == 0) { - vm.expectRevert(IRollup.InvalidBlobs.selector); - rollup.commitState(hex"01", hex"", PROOF); - } else if (mode % 3 == 1) { - bytes memory large = new bytes(24576); - vm.expectRevert(IRollup.OversizeBlobs.selector); - rollup.commitState(large, large, PROOF); - } else { - // No successful handler authorizes this proof. - vm.expectRevert(StatementVerifier.WrongStatement.selector); - rollup.commitState(hex"01", hex"02", hex"dead"); - } - ++rejected; - assertEq(rollup.chainLength(), beforeCount); - assertEq(rollup.anchorBlock(), anchor); - assertEq(vm.getNonce(address(rollup)), beforeNonce); - assertEq(rollup.stateAddress(beforeCount + 1, 0).code.length, 0); - assertEq(rollup.stateAddress(beforeCount + 1, 1).code.length, 0); - } -} - -contract RollupInvariantTest is StdInvariant, RollupFixture { - RollupHandler internal handler; - - function setUp() public override { - super.setUp(); - handler = new RollupHandler(rollup, verifier, VKEY, modelAnchor); - // Seed a real transition so history properties cannot pass only on genesis. - handler.advance(keccak256("initial transition"), 31, 1, address(123)); - bytes4[] memory selectors = new bytes4[](2); - selectors[0] = RollupHandler.advance.selector; - selectors[1] = RollupHandler.reject.selector; - targetSelector(FuzzSelector({addr: address(handler), selectors: selectors})); - targetContract(address(handler)); - // Top-level invariant transactions must originate from EOAs. Allowing - // the fuzzer to choose Rollup as tx sender artificially consumes its nonce. - targetSender(address(0xa11ce)); - targetSender(address(0xb0b)); - } - - function testHandlerNormalizesImpossibleSelfSender() public { - handler.advance(bytes32(uint256(512)), 101, 5, address(rollup)); - invariantHistoryAndSequencing(); - } - - function invariantHistoryAndSequencing() public view { - uint256 count = handler.count(); - assertEq(rollup.chainLength(), count); - assertEq(vm.getNonce(address(rollup)), count * 2 + 3, "Rollup CREATE nonce"); - assertEq(rollup.anchorBlock(), handler.anchor()); - for (uint256 i; i <= count; ++i) { - (bytes memory a, bytes memory n) = handler.blobs(i); - checkState(i, a, n); - assertEq(a.length, n.length); - // Every stored record plus one beyond the end, for all historical states. - for (uint256 token; token <= (a.length + 6) / 6; ++token) { - assertEq(rollup.state(i, 0, token), record(a, token)); - assertEq(rollup.state(i, type(uint256).max, token), record(n, token)); - } - } - (bytes memory latestA, bytes memory latestN) = handler.blobs(count); - assertEq(rollup.account(0), record(latestA, 0)); - assertEq(rollup.nonce(0), record(latestN, 0)); - assertEq(rollup.latestStateAddress(0), rollup.stateAddress(count, 0)); - assertEq(rollup.latestStateAddress(2), rollup.stateAddress(count, 1)); - } -} diff --git a/test/Rollup.t.sol b/test/Rollup.t.sol index a88a974..49bf4a6 100644 --- a/test/Rollup.t.sol +++ b/test/Rollup.t.sol @@ -1,303 +1,218 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.37; - -import {Test} from "forge-std/Test.sol"; import {Vm} from "forge-std/Vm.sol"; +import {Test} from "forge-std/Test.sol"; import {Rollup} from "../src/Rollup.sol"; import {IRollup} from "../src/interfaces/IRollup.sol"; import {ISP1Verifier} from "../src/interfaces/ISP1Verifier.sol"; -import {SSTORE2} from "solady@0.1.26/src/utils/SSTORE2.sol"; - -// Strict protocol-boundary double, NOT a cryptographic SP1 verifier. -// Every test authorizes an independently constructed statement and proof. -contract StatementVerifier is ISP1Verifier { - bytes32 public expected; - error WrongStatement(); - function authorize(bytes32 key, bytes memory inputs, bytes memory proof) external { - expected = keccak256(abi.encode(key, inputs, proof)); - } - - function verifyProof(bytes32 key, bytes calldata inputs, bytes calldata proof) external view { - if (keccak256(abi.encode(key, inputs, proof)) != expected) revert WrongStatement(); +contract TestVerifier is ISP1Verifier { + function verifyProof(bytes32, bytes calldata values, bytes calldata proof) external pure { + require(values.length == 32 && keccak256(values) == keccak256(proof), "statement mismatch"); } } -abstract contract RollupFixture is Test { - address internal constant GATEWAY = 0x397A5f7f3dBd538f23DE225B51f532c34448dA9B; - bytes32 internal constant VKEY = 0x005120317542200324c9509e78315ad70799268f02d21504709c8973d2493203; - bytes internal constant PROOF = hex"123456"; - Rollup internal rollup; - StatementVerifier internal verifier; - bytes internal oldA = hex"00"; - bytes internal oldN = hex"00"; - bytes32 internal modelAnchor; - - function setUp() public virtual { - vm.roll(100); - modelAnchor = keccak256("block 99"); - vm.setBlockhash(99, modelAnchor); - StatementVerifier template = new StatementVerifier(); - vm.etch(GATEWAY, address(template).code); - verifier = StatementVerifier(GATEWAY); - rollup = new Rollup(); - } +contract TestM3ter { + address public owner; - function statement(bytes32 anchor, bytes memory pa, bytes memory pn, bytes memory a, bytes memory n) - internal - pure - returns (bytes memory) - { - return bytes.concat( - anchor, keccak256(bytes.concat(hex"00", pa)), keccak256(bytes.concat(hex"00", pn)), hex"00", a, hex"00", n - ); - } - - function authorize(bytes memory a, bytes memory n) internal { - verifier.authorize(VKEY, statement(modelAnchor, oldA, oldN, a, n), PROOF); - } - - function commit(bytes memory a, bytes memory n) internal { - authorize(a, n); - rollup.commitState(a, n, PROOF); - oldA = a; - oldN = n; - modelAnchor = blockhash(block.number - 1); - } - - // Independent oracle: six consecutive bytes from runtime 00 || payload, - // right-padded with zero, without SSTORE2 offset arithmetic. - function record(bytes memory payload, uint256 token) internal pure returns (bytes6 value) { - bytes memory runtime = bytes.concat(hex"00", payload); - uint48 result; - for (uint256 j; j < 6; ++j) { - uint256 pos = token * 6 + j; - result = (result << 8) | (pos < runtime.length ? uint48(uint8(runtime[pos])) : uint48(0)); - } - return bytes6(result); + function setOwner(address next) external { + owner = next; } - function checkState(uint256 stateIndex, bytes memory a, bytes memory n) internal view { - address ap = vm.computeCreateAddress(address(rollup), stateIndex * 2 + 1); - address np = vm.computeCreateAddress(address(rollup), stateIndex * 2 + 2); - assertEq(rollup.stateAddress(stateIndex, 0), ap); - assertEq(rollup.stateAddress(stateIndex, 1), np); - assertEq(ap.code, bytes.concat(hex"00", a)); - assertEq(np.code, bytes.concat(hex"00", n)); + function ownerOf(uint256) external view returns (address) { + return owner; } } -contract RollupTest is RollupFixture { - function testGenesisAndSentinelEvent() public { - vm.recordLogs(); - Rollup fresh = new Rollup(); - Vm.Log[] memory logs = vm.getRecordedLogs(); - assertEq(logs.length, 1); - assertEq(logs[0].topics[0], keccak256("NewState(address,bytes32,uint256,bytes,bytes,bytes)")); - assertEq(logs[0].topics[1], bytes32(uint256(uint160(address(this))))); - assertEq(logs[0].topics[2], bytes32(0)); - assertEq(logs[0].topics[3], bytes32(0)); - assertEq(logs[0].data, abi.encode(hex"00", hex"00", hex"00")); - assertEq(fresh.anchorBlock(), modelAnchor); - assertEq(rollup.chainLength(), 0); - assertEq(vm.getNonce(address(rollup)), 3); - checkState(0, hex"00", hex"00"); - assertEq(rollup.account(0), bytes6(0)); - assertEq(rollup.nonce(100), bytes6(0)); - } - - function testConstantsAndLatestGetters() public { - assertEq(rollup.QUOTA(), 6); - assertEq(rollup.SP1_GROTH16_GATEWAY(), GATEWAY); - assertEq(rollup.SP1_PROGRAM_VKEY(), VKEY); - commit(hex"0102030405111213141516", hex"2122232425313233343536"); - assertEq(rollup.account(0), bytes6(hex"000102030405")); - assertEq(rollup.account(1), bytes6(hex"111213141516")); - assertEq(rollup.nonce(1), bytes6(hex"313233343536")); - assertEq(rollup.latestStateAddress(0), rollup.stateAddress(1, 0)); - assertEq(rollup.latestStateAddress(99), rollup.stateAddress(1, 1)); - } - - function testAnchorEventUsesVerifiedAnchorAndStorageAdvances() public { - bytes32 previous = modelAnchor; - vm.roll(105); - bytes32 next = keccak256("block 104"); - vm.setBlockhash(104, next); - authorize(hex"1234", hex"abcd"); - vm.recordLogs(); - vm.prank(address(0xbeef)); - rollup.commitState(hex"1234", hex"abcd", PROOF); - Vm.Log[] memory logs = vm.getRecordedLogs(); - assertEq(logs.length, 1); - assertEq(logs[0].topics[1], bytes32(uint256(0xbeef))); - assertEq(logs[0].topics[2], previous); - assertEq(logs[0].topics[3], bytes32(uint256(1))); - assertEq(logs[0].data, abi.encode(hex"1234", hex"abcd", PROOF)); - assertEq(rollup.anchorBlock(), next); +contract MutatingVerifier { + function verifyProof(bytes32, bytes calldata, bytes calldata) external { + assembly { sstore(0, 1) } } } -contract RollupProofTest is RollupFixture { - function testRejectTamperedProofAndPayloadAtomically() public { - authorize(hex"1234", hex"abcd"); - vm.expectRevert(StatementVerifier.WrongStatement.selector); - rollup.commitState(hex"1234", hex"abcd", hex"12"); - vm.expectRevert(StatementVerifier.WrongStatement.selector); - rollup.commitState(hex"1235", hex"abcd", PROOF); - vm.expectRevert(StatementVerifier.WrongStatement.selector); - rollup.commitState(hex"1234", hex"abce", PROOF); - assertEq(rollup.chainLength(), 0); - assertEq(rollup.anchorBlock(), modelAnchor); - assertEq(vm.getNonce(address(rollup)), 3); - assertEq(rollup.stateAddress(1, 0).code.length, 0); - } - - function testRejectWrongParentAnchorAndKey() public { - bytes memory a = hex"01"; - bytes memory n = hex"02"; - verifier.authorize(VKEY, statement(bytes32(uint256(1)), oldA, oldN, a, n), PROOF); - vm.expectRevert(StatementVerifier.WrongStatement.selector); - rollup.commitState(a, n, PROOF); - verifier.authorize(VKEY, statement(modelAnchor, hex"ff", oldN, a, n), PROOF); - vm.expectRevert(StatementVerifier.WrongStatement.selector); - rollup.commitState(a, n, PROOF); - verifier.authorize(VKEY, statement(modelAnchor, oldA, hex"ff", a, n), PROOF); - vm.expectRevert(StatementVerifier.WrongStatement.selector); - rollup.commitState(a, n, PROOF); - verifier.authorize(bytes32(0), statement(modelAnchor, oldA, oldN, a, n), PROOF); - vm.expectRevert(StatementVerifier.WrongStatement.selector); - rollup.commitState(a, n, PROOF); - } - - function testChangedParentRejectsReplayInSameBlock() public { - commit(hex"11", hex"22"); - // Keep previous authorization: parent hashes have now changed. - vm.expectRevert(StatementVerifier.WrongStatement.selector); - rollup.commitState(hex"11", hex"22", PROOF); - commit(hex"33", hex"44"); - assertEq(rollup.chainLength(), 2); - } - - function testChangedAnchorRejectsOldStatementEvenWhenStateUnchanged() public { - vm.roll(101); - vm.setBlockhash(100, keccak256("block 100")); - commit(hex"00", hex"00"); - vm.expectRevert(StatementVerifier.WrongStatement.selector); - rollup.commitState(hex"00", hex"00", PROOF); - } - - function testNoopReplayIsCurrentlyAllowedWhenStatementIsUnchanged() public { - // Characterization, not a recommendation: no sequence number is in the statement. - authorize(hex"00", hex"00"); - rollup.commitState(hex"00", hex"00", PROOF); - rollup.commitState(hex"00", hex"00", PROOF); - assertEq(rollup.chainLength(), 2); - checkState(2, hex"00", hex"00"); - } - - function testOldStoredAnchorDoesNotExpireAfter256Blocks() public { - vm.roll(1000); - vm.setBlockhash(999, keccak256("block 999")); - commit(hex"11", hex"22"); - assertEq(rollup.chainLength(), 1); - assertEq(rollup.anchorBlock(), keccak256("block 999")); - } - - function testMissingVerifierCannotAcceptCommit() public { - vm.etch(GATEWAY, hex""); - vm.expectRevert(); - rollup.commitState(hex"11", hex"22", PROOF); - assertEq(rollup.chainLength(), 0); - assertEq(vm.getNonce(address(rollup)), 3); - } - - function testShapeChecksBeforeVerifier() public { - vm.expectRevert(IRollup.InvalidBlobs.selector); - rollup.commitState(hex"01", hex"", PROOF); - bytes memory large = new bytes(24576); - vm.expectRevert(IRollup.OversizeBlobs.selector); - rollup.commitState(large, large, PROOF); - assertEq(vm.getNonce(address(rollup)), 3); - } -} - -contract RollupStorageTest is RollupFixture { - function testMaximumAndEmptyPayloads() public { - bytes memory a = new bytes(24575); - a[0] = 0x42; - a[24574] = 0xff; - commit(a, a); - checkState(1, a, a); - assertEq(rollup.account(4095), record(a, 4095)); - assertEq(rollup.account(4096), bytes6(0)); - commit(hex"", hex""); - checkState(2, hex"", hex""); - assertEq(rollup.account(0), bytes6(0)); - checkState(1, a, a); - } - - function testHistoricalReadsAcrossRlpNonceBoundaries() public { - for (uint256 i = 1; i <= 129; ++i) { - commit(abi.encode(i), abi.encode(i + 1000)); +contract RollupTest is Test { + Rollup r; + + function setUp() public { + r = new Rollup(); + vm.etch(address(r.VERIFIER()), address(new TestVerifier()).code); + vm.etch(r.M3ter(), address(new TestM3ter()).code); + TestM3ter(r.M3ter()).setOwner(address(this)); + } + + function oldWord(uint256 slot) internal view returns (uint256) { + return slot < r.wordCount() ? r.words(slot) : 0; + } + + function proofFor(bytes memory updates) internal view returns (bytes memory) { + bytes memory transcript = abi.encodePacked(block.chainid, address(r)); + for (uint256 offset; offset < updates.length; offset += 36) { + uint32 slot; + uint256 value; + assembly { + slot := shr(224, mload(add(add(updates, 32), offset))) + value := mload(add(add(updates, 36), offset)) + } + uint256 old = oldWord(slot); + bytes32[4] memory keys; + for (uint64 lane; lane < 4; ++lane) { + if (uint64(old >> (lane * 64)) != uint64(value >> (lane * 64))) { + keys[lane] = r.publicKey(uint64(slot) * 4 + lane); + } + } + transcript = bytes.concat(transcript, abi.encodePacked(slot, old, value, keccak256(abi.encode(keys)))); } - for (uint256 i = 1; i <= 129; ++i) { - checkState(i, abi.encode(i), abi.encode(i + 1000)); - assertEq(rollup.state(i, 0, 5), record(abi.encode(i), 5)); - } - assertEq(vm.getNonce(address(rollup)), 261); - } - - function testSecondCreateFailureRollsBackFirst() public { - address first = rollup.stateAddress(1, 0); - address second = rollup.stateAddress(1, 1); - // Artificial collision to exercise rollback, not an attacker deployment claim. - vm.etch(second, hex"00"); - authorize(hex"11", hex"22"); - vm.expectRevert(SSTORE2.DeploymentFailed.selector); - rollup.commitState{gas: 1000000}(hex"11", hex"22", PROOF); - assertEq(first.code.length, 0); - assertEq(rollup.chainLength(), 0); - assertEq(rollup.anchorBlock(), modelAnchor); - assertEq(vm.getNonce(address(rollup)), 3); - vm.etch(second, hex""); - commit(hex"11", hex"22"); - checkState(1, hex"11", hex"22"); - } - - function testPrefundingDoesNotBlockCreationAndAnySenderCanCommit() public { - vm.deal(rollup.stateAddress(1, 0), 1 ether); - authorize(hex"11", hex"22"); - vm.prank(address(123)); - rollup.commitState(hex"11", hex"22", PROOF); - checkState(1, hex"11", hex"22"); + return abi.encodePacked(keccak256(transcript)); + } + + function commit(uint32 slot, uint256 value) internal { + bytes memory updates = abi.encodePacked(slot, value); + r.commitState(updates, proofFor(updates)); + } + + function testOwnerAuthorityAndFullKeyPersistence() public { + r.setPublicKey(0, bytes32(uint256(123))); + commit(0, uint256(1) << 40); + assertEq(r.publicKey(0), bytes32(uint256(123))); + TestM3ter(r.M3ter()).setOwner(address(99)); + vm.expectRevert(IRollup.Unauthorized.selector); + r.setPublicKey(0, bytes32(0)); + vm.prank(address(99)); + r.setPublicKey(0, bytes32(0)); + assertEq(r.publicKey(0), bytes32(0)); + } + + function testUsedKeyRotationRevertsAllWritesAndLength() public { + bytes memory updates = abi.encodePacked(uint32(0), uint256(1) << 40, uint32(100), uint256(1) << 40); + bytes memory proof = proofFor(updates); + r.setPublicKey(0, bytes32(uint256(1))); + vm.expectRevert("statement mismatch"); + r.commitState(updates, proof); + assertEq(r.wordCount(), 0); + assertEq(vm.load(address(r), keccak256(abi.encode(uint256(0)))), bytes32(0)); + assertEq(vm.load(address(r), bytes32(uint256(keccak256(abi.encode(uint256(0)))) + 100)), bytes32(0)); + } + + function testUnusedKeyRotationAndDisjointCommitDoNotInvalidateProof() public { + bytes memory updates = abi.encodePacked(uint32(0), uint256(1) << 40); + bytes memory proof = proofFor(updates); + r.setPublicKey(1, bytes32(uint256(10))); + commit(1, uint256(1) << 40); + vm.roll(100000); // No anchor expiry. + r.commitState(updates, proof); + assertEq(r.nonce(0), 1); + assertEq(r.nonce(4), 1); + } + + function testReplayAndStaleOldWordRejected() public { + bytes memory updates = abi.encodePacked(uint32(0), uint256(2) << 40); + bytes memory proof = proofFor(updates); + commit(0, uint256(1) << 40); + vm.expectRevert("statement mismatch"); + r.commitState(updates, proof); + r.commitState(updates, proofFor(updates)); + vm.expectRevert(IRollup.InvalidUpdate.selector); + r.commitState(updates, proof); + } + + function testChainAndDeploymentBound() public { + bytes memory updates = abi.encodePacked(uint32(0), uint256(1) << 40); + bytes memory proof = proofFor(updates); + vm.chainId(block.chainid + 1); + vm.expectRevert("statement mismatch"); + r.commitState(updates, proof); + Rollup other = new Rollup(); + proof = proofFor(updates); + vm.expectRevert("statement mismatch"); + other.commitState(updates, proof); + } + + function testVerifierCannotMutateAndFailureRollsBack() public { + vm.etch(address(r.VERIFIER()), address(new MutatingVerifier()).code); + vm.expectRevert(); + r.commitState{gas: 500000}(abi.encodePacked(uint32(10), uint256(1) << 40), ""); + assertEq(r.wordCount(), 0); + assertEq(r.account(40), 0); } - function testFutureStateReadRevertsButAddressCanBePredicted() public { - assertTrue(rollup.stateAddress(1, 0) != address(0)); + function testMissingVerifierFailsClosed() public { + vm.etch(address(r.VERIFIER()), hex""); + vm.expectRevert(); + r.commitState{gas: 500000}(abi.encodePacked(uint32(10), uint256(1) << 40), ""); + assertEq(r.wordCount(), 0); + } + + function testFramingDuplicatesDescendingAndNoop() public { + vm.expectRevert(IRollup.InvalidEncoding.selector); + r.commitState("", ""); + vm.expectRevert(IRollup.InvalidEncoding.selector); + r.commitState(hex"01", ""); + vm.expectRevert(IRollup.InvalidUpdate.selector); + r.commitState(abi.encodePacked(uint32(1), uint256(1), uint32(1), uint256(2)), ""); + vm.expectRevert(IRollup.InvalidUpdate.selector); + r.commitState(abi.encodePacked(uint32(1), uint256(1), uint32(0), uint256(2)), ""); + vm.expectRevert(IRollup.InvalidUpdate.selector); + r.commitState(abi.encodePacked(uint32(0), uint256(0)), ""); + assertEq(r.wordCount(), 0); + } + + function testMaxSlotAndGetterBounds() public { + commit(type(uint32).max, uint256(7) << 192); + assertEq(r.wordCount(), uint256(1) << 32); + assertEq(r.account((uint64(1) << 34) - 1), 7); + assertEq(r.words(0), 0); vm.expectRevert(); - rollup.state(1, 0, 0); + r.words(uint256(1) << 32); + vm.expectRevert(IRollup.InvalidUpdate.selector); + r.setPublicKey(uint64(1) << 34, bytes32(uint256(1))); } - function testFuzzRecordsAndIo(bytes32 seed, uint16 size, uint16 token, uint256 io) public { - uint256 length = bound(size, 0, 512); - bytes memory a = new bytes(length); - bytes memory n = new bytes(length); - for (uint256 i; i < length; ++i) { - a[i] = seed[i % 32]; - n[i] = bytes1(~uint8(a[i])); + function testEventContainsExactCalldataUpdates() public { + bytes memory updates = abi.encodePacked(uint32(0), uint256(1) << 40, uint32(8), uint256(2) << 40); + bytes memory proof = proofFor(updates); + vm.recordLogs(); + r.commitState(updates, proof); + Vm.Log[] memory logs = vm.getRecordedLogs(); + assertEq(logs.length, 1); + assertEq(logs[0].topics[0], keccak256("StateCommitted(bytes)")); + assertEq(abi.decode(logs[0].data, (bytes)), updates); + } + + function testFuzzPacking(uint40 accountValue, uint24 nonceValue, uint8 rawLane) public { + uint64 lane = rawLane % 4; + uint256 pair = uint256(accountValue) | (uint256(nonceValue) << 40); + vm.assume(pair != 0); + commit(0, pair << (64 * lane)); + assertEq(r.account(lane), accountValue); + assertEq(r.nonce(lane), nonceValue); + assertEq(r.account((lane + 1) % 4), 0); + } + + function testFourByteSlotAboveOldBoundary() public { + uint32 slot = 0x01020304; + commit(slot, uint256(1) << 40); + assertEq(r.words(slot), uint256(1) << 40); + assertEq(r.nonce(uint64(slot) * 4), 1); + assertEq(r.wordCount(), uint256(slot) + 1); + vm.expectRevert(IRollup.InvalidEncoding.selector); + r.commitState(abi.encodePacked(uint24(1), uint256(1)), ""); + } + + function testRustFixture() public { + string memory f = vm.readFile("test/fixtures/statement.json"); + address target = address(0x1111111111111111111111111111111111111111); + vm.etch(target, address(r).code); + vm.chainId(1); + vm.store(target, bytes32(0), bytes32(uint256(2))); + vm.store(target, keccak256(abi.encode(uint256(0))), vm.parseJsonBytes32(f, ".oldWord")); + bytes32 key = 0x197f6b23e16c8532c6abc838facd5ea789be0c76b2920334039bfa8b3d368d61; + uint64[4] memory meters = [uint64(0), uint64(3), uint64(4), uint64(4000)]; + for (uint256 i; i < 4; ++i) { + vm.store(target, keccak256(abi.encode(uint256(meters[i]), uint256(1))), key); } - commit(a, n); - uint256 id = bound(token, 0, 100); - assertEq(rollup.state(1, io, id), record(io == 0 ? a : n, id)); - assertEq(rollup.account(id), record(a, id)); - assertEq(rollup.nonce(id), record(n, id)); - checkState(1, a, n); - } - - function testFuzzAddressNormalization(uint32 stateIndex, uint256 io) public view { - assertEq( - rollup.stateAddress(stateIndex, io), - vm.computeCreateAddress(address(rollup), uint256(stateIndex) * 2 + 1 + (io == 0 ? 0 : 1)) - ); + Rollup(target) + .commitState(vm.parseJsonBytes(f, ".payload"), abi.encodePacked(vm.parseJsonBytes32(f, ".commitment"))); + assertEq(Rollup(target).account(0), 41); + assertEq(Rollup(target).nonce(4), 1); } } diff --git a/test/RollupGas.t.sol b/test/RollupGas.t.sol new file mode 100644 index 0000000..ecca271 --- /dev/null +++ b/test/RollupGas.t.sol @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.37; +import {Test} from "forge-std/Test.sol"; +import {Rollup} from "../src/Rollup.sol"; +import {TestVerifier} from "./Rollup.t.sol"; + +contract RollupGasTest is Test { + function testGas4096WorstCase() public { + benchmark(4096, false, 4); + } + + function testGas128Reused() public { + benchmark(128, false, 4); + } + + function testGas512Reused() public { + benchmark(512, false, 4); + } + + function testGas768Reused() public { + benchmark(768, false, 4); + } + + function testGas1024Reused() public { + benchmark(1024, false, 4); + } + + function testGas128Fresh() public { + benchmark(128, true, 4); + } + + function testGas512Fresh() public { + benchmark(512, true, 4); + } + + function testGas512OneMeter() public { + benchmark(512, false, 1); + } + + function benchmark(uint256 count, bool fresh, uint256 changedMeters) internal { + Rollup target = new Rollup(); + vm.etch(address(target.VERIFIER()), address(new TestVerifier()).code); + bytes memory transcript = new bytes(52 + 100 * count); + bytes memory payload = new bytes(36 * count); + assembly { + mstore(add(transcript, 32), chainid()) + mstore(add(transcript, 64), shl(96, target)) + } + { + uint256 old; + uint256 value; + for (uint256 p; p < 4; ++p) { + if (!fresh) old |= (uint256(10000) | (uint256(100) << 40)) << (64 * p); + value |= (p < changedMeters + ? (uint256(10001) | (uint256(101) << 40)) + : (uint256(10000) | (uint256(100) << 40))) << (64 * p); + } + if (count == 4096) value = type(uint256).max; + bytes32[4] memory keys; + for (uint256 p; p < changedMeters; ++p) { + keys[p] = bytes32(uint256(7)); + } + bytes32 hash = keccak256(abi.encode(keys)); + uint256 base = uint256(keccak256(abi.encode(uint256(0)))); + for (uint256 i; i < count; ++i) { + if (!fresh) vm.store(address(target), bytes32(base + i), bytes32(old)); + for (uint256 p; p < 4; ++p) { + vm.store(address(target), keccak256(abi.encode(i * 4 + p, uint256(1))), bytes32(uint256(7))); + } + assembly { + let dest := add(add(transcript, 84), mul(i, 100)) + mstore(dest, shl(224, i)) + mstore(add(dest, 4), old) + mstore(add(dest, 36), value) + mstore(add(dest, 68), hash) + let wire := add(add(payload, 32), mul(i, 36)) + mstore(wire, shl(224, i)) + mstore(add(wire, 4), value) + } + } + if (!fresh) vm.store(address(target), bytes32(0), bytes32(count)); + } + measure(target, payload, abi.encodePacked(keccak256(transcript)), count); + } + + function measure(Rollup target, bytes memory payload, bytes memory proof, uint256 count) internal { + bytes memory callData = abi.encodeCall(target.commitState, (payload, proof)); + uint256 intrinsic = 21000; + for (uint256 i; i < callData.length; ++i) { + intrinsic += callData[i] == 0 ? 4 : 16; + } + vm.cool(address(target)); + vm.cool(address(target.VERIFIER())); + uint256 beforeGas = gasleft(); + target.commitState(payload, proof); + uint256 used = beforeGas - gasleft(); + emit log_named_uint("words", count); + emit log_named_uint("execution (mock verifier, includes CALL overhead)", used); + emit log_named_uint("intrinsic calldata gas (32-byte mock proof)", intrinsic); + emit log_named_uint("execution plus intrinsic", used + intrinsic); + emit log_named_uint("payload bytes", payload.length); + } +} diff --git a/test/RollupInvariant.t.sol b/test/RollupInvariant.t.sol new file mode 100644 index 0000000..85946d4 --- /dev/null +++ b/test/RollupInvariant.t.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.37; +import {Test} from "forge-std/Test.sol"; +import {StdInvariant} from "forge-std/StdInvariant.sol"; +import {Rollup} from "../src/Rollup.sol"; +import {TestM3ter, TestVerifier} from "./Rollup.t.sol"; + +contract RollupHandler is Test { + Rollup public r; + uint40[32] public accounts; + uint24[32] public nonces; + uint256 public commits; + + constructor(Rollup target) { + r = target; + } + + function update(uint8 rawMeter, uint16 mwh) external { + uint64 id = rawMeter % 32; + uint64 slot = id / 4; + uint256 old = (slot < r.wordCount() ? r.words(slot) : 0); + uint256 shift = 64 * (id % 4); + uint40 wh = accounts[id] + uint40(mwh / 1000); + uint24 nonce = nonces[id] + 1; + uint256 value = + (old & ~(uint256(type(uint64).max) << shift)) | ((uint256(wh) | (uint256(nonce) << 40)) << shift); + bytes32 keyHash = keccak256(abi.encode(bytes32(0), bytes32(0), bytes32(0), bytes32(0))); + bytes32 commitment = keccak256(abi.encodePacked(block.chainid, address(r), uint32(slot), old, value, keyHash)); + r.commitState(abi.encodePacked(uint32(slot), value), abi.encodePacked(commitment)); + accounts[id] = wh; + nonces[id] = nonce; + ++commits; + } +} + +contract RollupInvariantTest is StdInvariant, Test { + Rollup r; + RollupHandler h; + + function setUp() public { + r = new Rollup(); + vm.etch(address(r.VERIFIER()), address(new TestVerifier()).code); + h = new RollupHandler(r); + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = h.update.selector; + targetSelector(FuzzSelector(address(h), selectors)); + targetContract(address(h)); + } + + function invariantMatchesIndependentMeterModel() public view { + for (uint64 m; m < 32; ++m) { + assertEq(r.account(m), h.accounts(m)); + assertEq(r.nonce(m), h.nonces(m)); + } + assertLe(r.wordCount(), 8); + assertEq(r.account(32), 0); + } +} diff --git a/test/fixtures/statement.json b/test/fixtures/statement.json new file mode 100644 index 0000000..daa9c61 --- /dev/null +++ b/test/fixtures/statement.json @@ -0,0 +1,6 @@ +{ + "commitment": "0x772efb6498d80b6dc9137e37d4c4224a991505de619a6d07165da51b26a6e913", + "keyHash": "0x2c9d00d7c02a560bb8ec98516bcec77cdae44c2ddbf1898eaaa716d327a06230", + "oldWord": "0x000000000000000000000000000000000000000000000000000001000000000a", + "payload": "0x000000000000010000000063000000000000000000000000000000000000030000000029000000010000000000000000000000000000000000000000000000000000010000000000000003e80000000000000000000000000000000000000000000000000000010000000007" +} \ No newline at end of file