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..c405116 100644 --- a/README.md +++ b/README.md @@ -1,145 +1,129 @@ -# 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)`: +`m3terId(key)` returns the meter currently registered with a nonzero key. Keys are unique across meters: attempting to register a key already assigned to another meter reverts with `KeyAlreadyRegistered`. Setting the same key again for its existing meter is allowed. Rotation and revocation remove the previous reverse entry, making that key available for registration again. Unknown, revoked and zero keys revert with `KeyNotFound`, so a valid result of zero always means meter 0. Registration authenticates the NFT owner; it does not prove possession of the signing key. -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. +## Updates and proof statement -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. +`commitState(bytes updates, bytes proof)` takes concatenated **36-byte records**, sorted by strictly increasing slot: -## Anchoring the EVM state - -`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. - -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. +``` +uint32 slot, big endian 4 bytes +uint256 new word, big endian 32 bytes +``` -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. +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. -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. +Public values are exactly `keccak256(transcript)` as 32 bytes. The transcript has a 52-byte prefix and 100 bytes per changed word: -## Proof public values +| 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 | -The exact encoding is raw byte concatenation, **not** `abi.encode`: +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. -```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 -``` +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. -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. +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 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. +## Commit execution and events -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. +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 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. +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. -## Snapshot addresses +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. -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. +## Contract interface -For historical state index `s`: +| 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 | +| `m3terId(key)` | Meter ID for a registered key; reverts if absent/revoked/zero | +| `account(id)` | uint40 Wh account; zero for absent state | +| `nonce(id)` | uint24 nonce; zero for absent state | -```text -account CREATE nonce = 2*s + 1 -nonce CREATE nonce = 2*s + 2 -address = last20bytes(keccak256(RLP([address(rollup), CREATE nonce]))) -``` +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. -`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`. +The program key remains in slot 2; the reverse mapping is appended at slot 3 and stores meter ID plus one internally. Reverse lookup adds storage work to key registration/rotation/revocation, not to `commitState`. The guest ELF and statement encoding are unchanged. -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. +`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. -## Reading six-byte records +## Build and deployment -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. +Install Foundry 1.8.1, then: -| 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 | - -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. +```sh +forge soldeer install +forge fmt --check +forge build --sizes +FOUNDRY_PROFILE=ci forge test -vvv +``` -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. +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. -```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 Sepolia deployment is `0x9a9ED9b9A9BEC10d1B5E41b1873E78d19501D5Fa`; its transaction, program key, ELF checksum and meter 7 key registration are recorded in `deployments/sepolia.json`. It starts with zero accounts/nonces. Register keys through each meter's NFT owner and begin signed actions at nonce 1. -## Events - -```solidity -event NewState( - address indexed from, - bytes32 indexed anchorBlock, - uint256 indexed chainLength, - bytes accountBlob, - bytes nonceBlob, - bytes proof -); -``` +`VERIFIER` is `0x397A5f7f3dBd538f23DE225B51f532c34448dA9B`, and `M3ter` is `0x2c0008b6ea87802fc729A8f34B534F9D90d7Ac53`. `SP1_PROGRAM_VKEY` is storage-backed for testing. Only `VKEY_ADMIN` (the deploying wallet) can call `setProgramVKey(bytes32)`, only on chain ID 11155111, and zero keys are rejected. Every change emits `ProgramVKeyChanged`. This testing authority can change the transition rules, so it is a trust assumption, not a production upgrade mechanism. For a mainnet release, use a fixed, validated guest key and remove the test-only administration code. The setter is already disabled on other chains. -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. +Derive the program key from the exact companion guest ELF. The key/admin fields follow `words` and `publicKey`, preserving their storage slots 0 and 1. No historical state or old queue is imported into the fresh deployment. -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. +## Gas and batch size -## Build and test +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. -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. +Actual local Anvil Osaka receipts, cold storage, four changed meters per word, mock verifier and 32-byte mock proof: -```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 -``` +| Slots | Reused words | Fresh words | +|---:|---:|---:| +| 128 | 1,943,587 | 4,152,398 | +| 512 | 7,690,146 | 16,465,357 | -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 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: -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. +| 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 | -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. +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. -## What the tests establish +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. -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. +Reproduce local receipts after `forge build`: -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. +```sh +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 +``` -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/deployments/archive/2026-10-27/sepolia.json b/deployments/archive/2026-10-27/sepolia.json new file mode 100644 index 0000000..6b2f559 --- /dev/null +++ b/deployments/archive/2026-10-27/sepolia.json @@ -0,0 +1,18 @@ +{ + "chainId": 11155111, + "address": "0xAb683B638f634F0a56e83acAcB81D17900606A79", + "deployer": "0x8aC5E3097A39e5E1412AF04673085fC1026C0b57", + "programVKey": "0x00d7ccad99253ce7ee8b85917c04d23c3685645613492931c9ad82efecefe06d", + "elfSha256": "1cdba7eb2eae2c6839ec69074a9053cbf0cce030cb16d60642e8122145a3fe12", + "deploymentTransaction": "0xf8f4fd4c0f0bf67e80df5ffaf6158ff0038ad34e0b5fae4b03c426a96191ae89", + "deploymentBlock": 12004243, + "deploymentGas": "4814507", + "meter7KeyTransaction": "0xba36c536469af1979331a446bd8cd63d53cdc38b86bdfd4eea268be883e2ae94", + "meter7PublicKey": "0x2e653ffbcf13dabbf1a41d112171bab4ca1d3bac00e98d80cdce7cce51920608", + "verifier": "0x397A5f7f3dBd538f23DE225B51f532c34448dA9B", + "m3ter": "0x2c0008b6ea87802fc729A8f34B534F9D90d7Ac53", + "genesisWordCount": "0", + "etherscanVerification": "verified", + "sourcifyCreationMatch": "exact_match", + "sourcifyRuntimeMatch": "exact_match" +} diff --git a/deployments/archive/2026-10-27/verification-etherscan.log b/deployments/archive/2026-10-27/verification-etherscan.log new file mode 100644 index 0000000..e0e8168 --- /dev/null +++ b/deployments/archive/2026-10-27/verification-etherscan.log @@ -0,0 +1,27 @@ +Start verifying contract `0xAb683B638f634F0a56e83acAcB81D17900606A79` deployed on sepolia +Compiler version: 0.8.37+commit.f401782d + +Verifying on etherscan... +Submitting verification for [src/Rollup.sol:Rollup] 0xAb683B638f634F0a56e83acAcB81D17900606A79. +Submitted contract for verification: + Response: `OK` + GUID: `h7ff5qxndvehfmzcjrjufzymn2sc4y7rhykjkhpcz5iy34cyw5` + URL: https://sepolia.etherscan.io/address/0xab683b638f634f0a56e83acacb81d17900606a79 +h7ff5qxndvehfmzcjrjufzymn2sc4y7rhykjkhpcz5iy34cyw5 https://sepolia.etherscan.io/address/0xab683b638f634f0a56e83acacb81d17900606a79 + +Verifying on sourcify... +Submitting verification for [Rollup] 0xAb683B638f634F0a56e83acAcB81D17900606A79. +Submitted contract for verification: + Verification Job ID: `9c41b543-3070-4afe-be12-cbc9cf1b6ed4` + URL: https://sourcify.dev/server/verify-ui/jobs/9c41b543-3070-4afe-be12-cbc9cf1b6ed4 +9c41b543-3070-4afe-be12-cbc9cf1b6ed4 https://sourcify.dev/server/verify-ui/jobs/9c41b543-3070-4afe-be12-cbc9cf1b6ed4 + +Waiting for etherscan verification result... +Contract verification status: +Response: `NOTOK` +Details: `Pending in queue` +Warning: Verification is still pending...; waiting 5 seconds before trying again (4 tries remaining) +Contract verification status: +Response: `OK` +Details: `Pass - Verified` +Contract successfully verified diff --git a/deployments/archive/2026-10-27/verification-sourcify.log b/deployments/archive/2026-10-27/verification-sourcify.log new file mode 100644 index 0000000..bcdacad --- /dev/null +++ b/deployments/archive/2026-10-27/verification-sourcify.log @@ -0,0 +1,5 @@ +Start verifying contract `0xAb683B638f634F0a56e83acAcB81D17900606A79` deployed on sepolia +Compiler version: 0.8.37+commit.f401782d + +Verifying on sourcify... +Contract [Rollup] "0xAb683B638f634F0a56e83acAcB81D17900606A79" is already verified. Skipping verification. diff --git a/deployments/sepolia.json b/deployments/sepolia.json new file mode 100644 index 0000000..231ce40 --- /dev/null +++ b/deployments/sepolia.json @@ -0,0 +1,20 @@ +{ + "chainId": 11155111, + "address": "0x9a9ED9b9A9BEC10d1B5E41b1873E78d19501D5Fa", + "deployer": "0x8aC5E3097A39e5E1412AF04673085fC1026C0b57", + "programVKey": "0x00d7ccad99253ce7ee8b85917c04d23c3685645613492931c9ad82efecefe06d", + "elfSha256": "1cdba7eb2eae2c6839ec69074a9053cbf0cce030cb16d60642e8122145a3fe12", + "deploymentTransaction": "0xaeb2af588768e5fce1e407e673d38500a2992282b8ea36d46f9ad864c5216161", + "deploymentBlock": 12010691, + "deploymentGas": "5491196", + "meter7KeyTransaction": "0x98a74ee6bb39b571b19866b06bd19f3e6fc3e7142de046fc2f019738ecfddac1", + "meter7ReverseLookup": "7", + "meter7PublicKey": "0x2e653ffbcf13dabbf1a41d112171bab4ca1d3bac00e98d80cdce7cce51920608", + "verifier": "0x397A5f7f3dBd538f23DE225B51f532c34448dA9B", + "m3ter": "0x2c0008b6ea87802fc729A8f34B534F9D90d7Ac53", + "genesisWordCount": "0", + "etherscanVerification": "verified", + "sourcifyCreationMatch": "exact_match", + "sourcifyRuntimeMatch": "exact_match", + "runtimeMatchesBuildArtifact": true +} diff --git a/deployments/verification-etherscan.log b/deployments/verification-etherscan.log new file mode 100644 index 0000000..270dcc3 --- /dev/null +++ b/deployments/verification-etherscan.log @@ -0,0 +1,27 @@ +Start verifying contract `0x9a9ED9b9A9BEC10d1B5E41b1873E78d19501D5Fa` deployed on sepolia +Compiler version: 0.8.37+commit.f401782d + +Verifying on etherscan... +Submitting verification for [src/Rollup.sol:Rollup] 0x9a9ED9b9A9BEC10d1B5E41b1873E78d19501D5Fa. +Submitted contract for verification: + Response: `OK` + GUID: `qstrqqt5rwl757hv3qcbxpwiibzg39kgvihap7bsrrtutxqrri` + URL: https://sepolia.etherscan.io/address/0x9a9ed9b9a9bec10d1b5e41b1873e78d19501d5fa +qstrqqt5rwl757hv3qcbxpwiibzg39kgvihap7bsrrtutxqrri https://sepolia.etherscan.io/address/0x9a9ed9b9a9bec10d1b5e41b1873e78d19501d5fa + +Verifying on sourcify... +Submitting verification for [Rollup] 0x9a9ED9b9A9BEC10d1B5E41b1873E78d19501D5Fa. +Submitted contract for verification: + Verification Job ID: `1277459b-ffa4-4340-a2f8-807e7610c3af` + URL: https://sourcify.dev/server/verify-ui/jobs/1277459b-ffa4-4340-a2f8-807e7610c3af +1277459b-ffa4-4340-a2f8-807e7610c3af https://sourcify.dev/server/verify-ui/jobs/1277459b-ffa4-4340-a2f8-807e7610c3af + +Waiting for etherscan verification result... +Contract verification status: +Response: `NOTOK` +Details: `Pending in queue` +Warning: Verification is still pending...; waiting 5 seconds before trying again (4 tries remaining) +Contract verification status: +Response: `OK` +Details: `Pass - Verified` +Contract successfully verified diff --git a/deployments/verification-sourcify.log b/deployments/verification-sourcify.log new file mode 100644 index 0000000..c7e6395 --- /dev/null +++ b/deployments/verification-sourcify.log @@ -0,0 +1,5 @@ +Start verifying contract `0x9a9ED9b9A9BEC10d1B5E41b1873E78d19501D5Fa` deployed on sepolia +Compiler version: 0.8.37+commit.f401782d + +Verifying on sourcify... +Contract [Rollup] "0x9a9ED9b9A9BEC10d1B5E41b1873E78d19501D5Fa" is already verified. Skipping verification. 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; + ISP1Verifier public constant VERIFIER = ISP1Verifier(0x397A5f7f3dBd538f23DE225B51f532c34448dA9B); + // Sepolia testing only. Rebuild/re-derive this key when changing the guest. + bytes32 public SP1_PROGRAM_VKEY = 0x00d7ccad99253ce7ee8b85917c04d23c3685645613492931c9ad82efecefe06d; + address public immutable VKEY_ADMIN = msg.sender; + address public constant M3ter = 0x2c0008b6ea87802fc729A8f34B534F9D90d7Ac53; + uint256 public constant MAX_SLOT = type(uint32).max; + // ID + 1 distinguishes meter 0 from an unregistered key; zero keys are never indexed. + mapping(bytes32 => uint64) private _m3terIdPlusOne; - 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++; + function setPublicKey(uint64 id, bytes32 key) external { + if (id / 4 > MAX_SLOT) revert InvalidUpdate(); + if (IM3ter(M3ter).ownerOf(id) != msg.sender) revert Unauthorized(); + bytes32 previousKey = publicKey[id]; + if (key != previousKey) { + if (key != bytes32(0)) { + if (_m3terIdPlusOne[key] != 0) revert KeyAlreadyRegistered(); + _m3terIdPlusOne[key] = id + 1; + } + if (previousKey != bytes32(0)) delete _m3terIdPlusOne[previousKey]; + publicKey[id] = key; + } + emit KeyChanged(id, key); + } - // 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); + /// @notice Test-only authority; disabled on every chain except Ethereum Sepolia. + function setProgramVKey(bytes32 newVKey) external { + if (block.chainid != 11155111 || msg.sender != VKEY_ADMIN) revert Unauthorized(); + if (newVKey == bytes32(0)) revert InvalidUpdate(); + bytes32 previousVKey = SP1_PROGRAM_VKEY; + SP1_PROGRAM_VKEY = newVKey; + emit ProgramVKeyChanged(previousVKey, newVKey); + } - anchorBlock = blockhash(block.number - 1); + function wordCount() external view returns (uint256) { + return words.length; } - function account(uint256 tokenId) external view returns (bytes6) { - return state(chainLength, 0, tokenId); + /// @notice Look up the meter using a currently registered, nonzero public key. + function m3terId(bytes32 key) external view returns (uint64) { + uint64 encoded = _m3terIdPlusOne[key]; + if (encoded == 0) revert KeyNotFound(); + return encoded - 1; } - 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..7897fb5 100644 --- a/src/interfaces/IRollup.sol +++ b/src/interfaces/IRollup.sol @@ -1,39 +1,28 @@ // 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(); + error KeyAlreadyRegistered(); + error KeyNotFound(); + event KeyChanged(uint64 indexed id, bytes32 key); + event StateCommitted(bytes updates); + event ProgramVKeyChanged(bytes32 previousVKey, bytes32 newVKey); + + function commitState(bytes calldata updates, bytes calldata proof) external; + function setPublicKey(uint64 id, bytes32 key) external; + function setProgramVKey(bytes32 newVKey) external; + + function words(uint256 slot) external view returns (uint256); + function wordCount() external view returns (uint256); + function publicKey(uint64 id) external view returns (bytes32); + function m3terId(bytes32 key) external view returns (uint64); + function account(uint64 id) external view returns (uint40); + function nonce(uint64 id) external view returns (uint24); } diff --git a/test/Keystore.t.sol b/test/Keystore.t.sol new file mode 100644 index 0000000..9ebf1f4 --- /dev/null +++ b/test/Keystore.t.sol @@ -0,0 +1,159 @@ +// 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 {TestM3ter} from "./Rollup.t.sol"; + +contract KeystoreTest is Test { + Rollup r; + + function setUp() public { + r = new Rollup(); + vm.etch(r.M3ter(), address(new TestM3ter()).code); + TestM3ter(r.M3ter()).setOwner(address(this)); + } + + function testZeroIdAndUnknownKeys() public { + bytes32 key = bytes32(uint256(7)); + vm.expectRevert(IRollup.KeyNotFound.selector); + r.m3terId(key); + r.setPublicKey(0, key); + assertEq(r.m3terId(key), 0); + r.setPublicKey(0, key); // Idempotent registration keeps both directions. + assertEq(r.publicKey(0), key); + assertEq(r.m3terId(key), 0); + vm.expectRevert(IRollup.KeyNotFound.selector); + r.m3terId(bytes32(0)); + } + + function testDuplicateRejectedWithoutLosingPreviousKey() public { + bytes32 first = bytes32(uint256(1)); + bytes32 second = bytes32(uint256(2)); + r.setPublicKey(0, first); + r.setPublicKey(7, second); + vm.expectRevert(IRollup.KeyAlreadyRegistered.selector); + r.setPublicKey(7, first); + assertEq(r.publicKey(0), first); + assertEq(r.publicKey(7), second); + assertEq(r.m3terId(first), 0); + assertEq(r.m3terId(second), 7); + } + + function testRotationRevocationAndReuse() public { + bytes32 first = bytes32(uint256(1)); + bytes32 second = bytes32(uint256(2)); + r.setPublicKey(7, first); + r.setPublicKey(7, second); + vm.expectRevert(IRollup.KeyNotFound.selector); + r.m3terId(first); + assertEq(r.m3terId(second), 7); + r.setPublicKey(0, first); + assertEq(r.m3terId(first), 0); + r.setPublicKey(7, bytes32(0)); + r.setPublicKey(7, bytes32(0)); + assertEq(r.publicKey(7), bytes32(0)); + vm.expectRevert(IRollup.KeyNotFound.selector); + r.m3terId(second); + vm.expectRevert(IRollup.KeyNotFound.selector); + r.m3terId(bytes32(0)); + r.setPublicKey(8, second); + assertEq(r.m3terId(second), 8); + } + + function testTransferRetainsKeyAndOnlyNewOwnerCanRotate() public { + bytes32 key = bytes32(uint256(1)); + r.setPublicKey(7, key); + TestM3ter(r.M3ter()).setOwner(address(99)); + assertEq(r.m3terId(key), 7); + vm.expectRevert(IRollup.Unauthorized.selector); + r.setPublicKey(7, bytes32(0)); + assertEq(r.m3terId(key), 7); + vm.prank(address(99)); + r.setPublicKey(7, bytes32(0)); + vm.expectRevert(IRollup.KeyNotFound.selector); + r.m3terId(key); + } + + function testMaximumMeterId() public { + uint64 id = (uint64(1) << 34) - 1; + r.setPublicKey(id, bytes32(type(uint256).max)); + assertEq(r.m3terId(bytes32(type(uint256).max)), id); + vm.expectRevert(IRollup.InvalidUpdate.selector); + r.setPublicKey(id + 1, bytes32(uint256(2))); + vm.expectRevert(IRollup.KeyNotFound.selector); + r.m3terId(bytes32(uint256(2))); + } + + function testFuzzBidirectionalLookupAndRevoke(uint64 rawId, bytes32 key) public { + vm.assume(key != bytes32(0)); + uint64 id = rawId % (uint64(1) << 34); + r.setPublicKey(id, key); + assertEq(r.publicKey(id), key); + assertEq(r.m3terId(key), id); + r.setPublicKey(id, bytes32(0)); + assertEq(r.publicKey(id), bytes32(0)); + vm.expectRevert(IRollup.KeyNotFound.selector); + r.m3terId(key); + } +} + +contract KeystoreHandler is Test { + Rollup public r; + bytes32[8] public keys; + uint64[9] public ownersPlusOne; + + constructor(Rollup target) { + r = target; + } + + function register(uint8 rawId, uint8 rawKey) external { + uint64 id = rawId % 8; + uint256 keyNumber = rawKey % 9; + bytes32 key = bytes32(keyNumber); + if (keyNumber != 0 && ownersPlusOne[keyNumber] != 0 && ownersPlusOne[keyNumber] != id + 1) { + vm.expectRevert(IRollup.KeyAlreadyRegistered.selector); + r.setPublicKey(id, key); + return; + } + r.setPublicKey(id, key); + if (keys[id] != bytes32(0)) ownersPlusOne[uint256(keys[id])] = 0; + keys[id] = key; + if (keyNumber != 0) ownersPlusOne[keyNumber] = id + 1; + } +} + +contract KeystoreInvariantTest is StdInvariant, Test { + Rollup r; + KeystoreHandler h; + + function setUp() public { + r = new Rollup(); + vm.etch(r.M3ter(), address(new TestM3ter()).code); + h = new KeystoreHandler(r); + TestM3ter(r.M3ter()).setOwner(address(h)); + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = h.register.selector; + targetSelector(FuzzSelector(address(h), selectors)); + targetContract(address(h)); + } + + function invariantBothDirectionsMatchModel() public { + for (uint64 id; id < 8; ++id) { + bytes32 key = h.keys(id); + assertEq(r.publicKey(id), key); + if (key != bytes32(0)) assertEq(r.m3terId(key), id); + } + for (uint256 keyNumber; keyNumber < 9; ++keyNumber) { + uint64 ownerPlusOne = h.ownersPlusOne(keyNumber); + if (ownerPlusOne == 0) { + vm.expectRevert(IRollup.KeyNotFound.selector); + r.m3terId(bytes32(keyNumber)); + } else { + assertEq(r.m3terId(bytes32(keyNumber)), ownerPlusOne - 1); + } + } + assertEq(r.wordCount(), 0); + } +} 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..b48b3dd 100644 --- a/test/Rollup.t.sol +++ b/test/Rollup.t.sol @@ -1,303 +1,237 @@ // 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 testVKeyUpdatesOnlyByDeployerOnSepolia() public { + bytes32 beforeKey = r.SP1_PROGRAM_VKEY(); + vm.chainId(1); + vm.expectRevert(IRollup.Unauthorized.selector); + r.setProgramVKey(bytes32(uint256(2))); + vm.chainId(11155111); + vm.prank(address(0xBAD)); + vm.expectRevert(IRollup.Unauthorized.selector); + r.setProgramVKey(bytes32(uint256(2))); + vm.expectRevert(IRollup.InvalidUpdate.selector); + r.setProgramVKey(bytes32(0)); + assertEq(r.SP1_PROGRAM_VKEY(), beforeKey); + vm.expectEmit(address(r)); + emit IRollup.ProgramVKeyChanged(beforeKey, bytes32(uint256(2))); + r.setProgramVKey(bytes32(uint256(2))); + assertEq(r.SP1_PROGRAM_VKEY(), bytes32(uint256(2))); + assertEq(r.VKEY_ADMIN(), 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