diff --git a/README.md b/README.md index 8410483..9db7658 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,15 @@ WASM contract for Magi that verifies SP1 Groth16 proofs of Ethereum consensus an | Action | Caller | Description | |--------|--------|-------------| -| `init` | Owner | Set Groth16 VK, VK root, and SP1 program vkey hash | -| `updateVkey` | Owner | Update verification parameters (for SP1 version upgrades) | -| `submitProof` | Anyone | Submit ZK proof + headers. Proof must be valid. Max 12 headers per tx. | +| `init` | Owner | One-shot. Set Groth16 VK, VK root, SP1 program vkey hash, anchor (initial block hash + height), `expected_chain_id`, and `is_testnet`. Re-entry rejected. | +| `propose` | Owner | Queue an admin action (`updateVkey` or `setExpectedElfHash` rotation) under the **400K-block timelock** (~14 days). Returns a proposal id. | +| `execute` | Owner | Apply a previously-proposed action after its timelock has elapsed. | +| `cancelProposal` | Owner | Cancel a still-pending proposal before its `execHeight`. | +| `expireProposal` | Anyone | Permissionless garbage-collect of a proposal whose `expireHeight` has passed. | +| `setExpectedElfHash` | Owner | **First pin only** (deploy ramp-up, no trusted prior hash to rotate from). After the first call, this action is rejected — rotations go through `propose` + `execute`. | +| `submitProof` | Anyone | Submit ZK proof + headers. Proof must be valid. Max 12 headers per tx. Rejected if `provenChainId != expected_chain_id`. | + +`updateVkey` is no longer a direct wasmexport — it is reachable only via `propose` (action `"updateVkey"`) and `execute` after the 400K-block timelock (F1 fix). The init path remains the one place a vkey is installed without timelock (no trusted prior state to rotate from). ## State keys @@ -62,8 +68,27 @@ After deployment, call `init` with: { "groth16_vk": "", "vk_root": "", - "sp1_vkey_hash": "" + "sp1_vkey_hash": "", + "max_retention": 50400, + "initial_height": 12345678, + "initial_block_hash": "0x<64-hex: finalized L1 block hash at initial_height>", + "expected_chain_id": 1, + "is_testnet": false } ``` These values come from the SP1-Helios fork's build output and the SP1 verifier artifacts. + +Required / important fields (init fails closed if missing): + +- `initial_height` + `initial_block_hash` — CRIT #25 anchor. The first + `submitProof` batch must produce a header whose `ParentHash` equals + `initial_block_hash`. Pick a recent finalized L1 block and its hash from a + trusted source. Both REQUIRED (init aborts if absent/zero). +- `expected_chain_id` — CRIT #6 Site 6 chain binding. The L1 chainId this + verifier is bound to (`1` mainnet, `11155111` Sepolia). REQUIRED, non-zero; + `submitProof` reverts any proof whose committed chainId differs. Immutable + after init (no setter). +- `max_retention` — optional; defaults to `DefaultMaxRetention` when `0`. +- `is_testnet` — set `true` ONLY on testnet so `clearTestnetState` is + permitted; leave `false`/unset on mainnet (the sentinel gates that handler). diff --git a/contract/contracterrors/errors.go b/contract/contracterrors/errors.go index 8cc7943..2a79b87 100644 --- a/contract/contracterrors/errors.go +++ b/contract/contracterrors/errors.go @@ -17,6 +17,7 @@ const ( ErrInput = ErrorSymbol("bad_input") ErrInvalidHex = ErrorSymbol("invalid_hex") ErrInitialization = ErrorSymbol("contract_not_initialized") + ErrState = ErrorSymbol("invalid_state_transition") ErrIntent = ErrorSymbol("intent_error") ErrBalance = ErrorSymbol("insufficient_balance") ErrArithmetic = ErrorSymbol("overflow_underflow") diff --git a/contract/main.go b/contract/main.go index 3b3529b..66f0095 100644 --- a/contract/main.go +++ b/contract/main.go @@ -11,12 +11,37 @@ import ( func main() {} const ( - KeyLastHeight = "h" - KeyBlockPrefix = "b-" - KeyGroth16Vk = "vk" - KeyVkRoot = "vr" - KeySp1VkeyHash = "sp1vk" + KeyLastHeight = "h" + KeyBlockPrefix = "b-" + KeyGroth16Vk = "vk" + KeyVkRoot = "vr" + KeySp1VkeyHash = "sp1vk" KeyMaxRetention = "mr" + + // W4 Cluster B CRIT #25 — cross-batch parent anchor. + // On every successful submitProof, KeyLastBlockHash is updated to the + // last (highest-numbered) block hash in the batch. The next batch must + // satisfy `parsed[0].ParentHash == lastBlockHash` at the top of + // submitProof, preventing a forged "first proof" from injecting an + // arbitrary state. Seeded by `initContract` via the new + // `initial_block_hash` parameter (atomic backfill at deploy time). + KeyLastBlockHash = "lbh" + + // W4 Cluster B HIGH #22 — ELF hash pinning. + // SHA-256 of the SP1 ELF binary the prover is expected to use. The + // prover startup check reads this and fatal-exits on mismatch. Set + // via `setExpectedElfHash` admin handler (Fund-affecting class, + // 400K-block timelock — owned by Cluster B per v18 §AI). + KeyExpectedElfHash = "elfhash" + + // W4 Cluster B CRIT #6 Site 6 enforcement: the chainId this verifier + // instance is bound to. Set at init from InitContractParams.ExpectedChainId. + // submitProof rejects any proof whose committed chainId (slot 11 of + // ProofOutputs) does not equal this value — otherwise a valid SP1 proof + // for the WRONG chain (e.g. a Sepolia proof against a mainnet verifier) + // would be accepted and its headers stored as authoritative. + KeyExpectedChainId = "cid" + DefaultMaxRetention = uint64(10000) // Max headers per transaction. Limited by Magi's 16KB MAX_TX_SIZE. @@ -24,49 +49,222 @@ const ( MaxHeadersPerTx = 12 // ProofOutputs ABI layout (alloy::abi_encode of the SP1 program's output struct). - // alloy emits a 32-byte tuple head offset (always 0x20), then 8 head fields of - // 32 bytes each, then the dynamic tail. This contract reads fields 5/6/7 only. - // Offsets are computed from a fixed PvAbiOffset = 32; we assert the runtime - // value equals that constant before indexing, so int conversion is unnecessary. - PvAbiOffset = 32 // alloy tuple head, asserted equal at runtime - PvFieldStateRoot = PvAbiOffset + 5*32 // 192 - PvFieldBlockHash = PvAbiOffset + 6*32 // 224 - PvFieldBlockNumber = PvAbiOffset + 7*32 // 256 - PvMinLen = PvAbiOffset + 8*32 // 288 — 8 head fields fully present + // + // W4 Cluster B CRIT #6 Site 6 + W1-cluster-B-schema §1 LOCKED (v17.1 §G): + // 12 head slots: 10 static fields (slots 0-9) + slot 10 (storageSlots + // dynamic offset pointer) + slot 11 (chainId, NEW — appended last in the + // sol! struct definition in sp1-helios-magi/primitives/src/types.rs). + // This contract reads slots 5/6/7 (stateRoot/blockHash/blockNumber) for + // legacy reads and slot 11 (chainId) for chain-binding verification. + // PvMinLen=288 covers legacy slot 5/6/7 reads (kept for backward-compat + // parsers that don't need chainId); PvMinLenWithChainId=416 covers the + // full extended struct and MUST be checked before reading PvFieldChainId. + // + // Append-last position rationale: inserting chainId before storageSlots + // would shift the slot-10 dynamic offset pointer and break every + // positional decoder. Appending preserves slots 0-10 byte-for-byte. + PvAbiOffset = 32 // alloy tuple head, asserted equal at runtime + PvFieldStateRoot = PvAbiOffset + 5*32 // 192 + PvFieldBlockHash = PvAbiOffset + 6*32 // 224 + PvFieldBlockNumber = PvAbiOffset + 7*32 // 256 + PvMinLen = PvAbiOffset + 8*32 // 288 — legacy: 8 head slots fully present + PvFieldChainId = PvAbiOffset + 11*32 // 384 — slot 11 (chainId) + PvMinLenWithChainId = PvAbiOffset + 12*32 // 416 — 12 head slots (covers chainId read) ) // --- Admin actions --- +// InitContractParams — W1-cluster-B-schema §3 + v20 §BG LOCKED. +// Extended with: +// - InitialBlockHash + InitialHeight (CRIT #25 atomic anchor backfill) +// - IsTestnet (Cluster E clearTestnetState gate sentinel) +type InitContractParams struct { + Groth16Vk string `json:"groth16_vk"` + VkRoot string `json:"vk_root"` + Sp1VkeyHash string `json:"sp1_vkey_hash"` + MaxRetention uint64 `json:"max_retention"` + InitialHeight uint64 `json:"initial_height"` + InitialBlockHash string `json:"initial_block_hash"` + IsTestnet bool `json:"is_testnet"` + // W4 Cluster B CRIT #6 Site 6: the L1 chainId this verifier is bound to + // (e.g. 1 mainnet, 11155111 Sepolia). submitProof enforces that every + // proof's committed chainId equals this. Required (non-zero) at init. + ExpectedChainId uint64 `json:"expected_chain_id"` +} + //go:wasmexport init func initContract(input *string) *string { checkOwner() - var params struct { - Groth16Vk string `json:"groth16_vk"` - VkRoot string `json:"vk_root"` - Sp1VkeyHash string `json:"sp1_vkey_hash"` + + // One-shot re-entry guard. First call (deploy-time) installs the vkey + // state with no timelock — submitProof cannot function until init runs, + // so a 14-day gate on first init would brick the bridge. Re-entry (after + // vkey state is installed) is blocked entirely; subsequent vkey rotations + // MUST go through propose() then execute() with the 400K-block timelock + // (see timelock.go — F1 fix; this is now a real on-chain mechanism, not a + // comment). Re-entry guard also covers the case where a rotation has run + // (execute -> applyUpdateVkey writes KeyGroth16Vk). + if existing := sdk.StateGetObject(KeyGroth16Vk); existing != nil && *existing != "" { + ce.Abort(ce.ErrState, "already initialized — use propose/execute (400K-block timelock) to rotate the vkey", "init") } + + var params InitContractParams if err := json.Unmarshal([]byte(*input), ¶ms); err != nil { ce.Abort(ce.ErrJson, "invalid JSON", "init") } if params.Groth16Vk == "" || params.VkRoot == "" || params.Sp1VkeyHash == "" { ce.Abort(ce.ErrInput, "groth16_vk, vk_root, and sp1_vkey_hash required", "init") } + + // W4 Cluster B CRIT #25 + B-B-4: require initial anchor params. + // Without these, submitProof would either reject all proofs ("anchor + // not set") or accept a forged batch whose ParentHash matches the + // uninitialized zero hash. The guard closes the N2 bypass window. + if params.InitialBlockHash == "" || params.InitialHeight == 0 { + ce.Abort(ce.ErrInput, "initial_block_hash and initial_height required for anchor", "init") + } + // Sanity-check the anchor hash shape (0x-prefixed 64-hex == 32 bytes). + if !looksLikeBlockHash(params.InitialBlockHash) { + ce.Abort(ce.ErrInput, "initial_block_hash must be 0x-prefixed 32-byte hex", "init") + } + + // W4 Cluster B CRIT #6 Site 6: require the bound chainId. Without it, + // submitProof has nothing to compare provenChainId against and the + // chain-binding fix is inert (the bug the audit found). + if params.ExpectedChainId == 0 { + ce.Abort(ce.ErrInput, "expected_chain_id required (non-zero) for chain-binding enforcement", "init") + } + sdk.StateSetObject(KeyGroth16Vk, params.Groth16Vk) sdk.StateSetObject(KeyVkRoot, params.VkRoot) sdk.StateSetObject(KeySp1VkeyHash, params.Sp1VkeyHash) - sdk.StateSetObject(KeyMaxRetention, strconv.FormatUint(DefaultMaxRetention, 10)) + + retention := params.MaxRetention + if retention == 0 { + retention = DefaultMaxRetention + } + sdk.StateSetObject(KeyMaxRetention, strconv.FormatUint(retention, 10)) + + // CRIT #25 atomic anchor: seed KeyLastBlockHash and last-height so the + // first submitProof batch must produce a header whose ParentHash equals + // this anchor. Seeded values come from the W0 operator runbook P5 step + // (operator selects a recent finalized L1 block, queries its hash via + // any trusted source, includes here at deploy time). + sdk.StateSetObject(KeyLastBlockHash, params.InitialBlockHash) + sdk.StateSetObject(KeyLastHeight, strconv.FormatUint(params.InitialHeight, 10)) + + // W4 Cluster B CRIT #6 Site 6: persist the bound chainId for submitProof + // enforcement. Immutable after init (no setter handler — chain binding + // must not be admin-mutable, same rationale as account-mapping dropping + // setChainId per CRIT #6 Site 12). + sdk.StateSetObject(KeyExpectedChainId, strconv.FormatUint(params.ExpectedChainId, 10)) + + // v20 §BG: testnet sentinel for Cluster E's clearTestnetState. ABSENCE + // of this key == mainnet; presence == testnet. Mainnet deploys MUST + // pass IsTestnet=false; clearTestnetState reads this sentinel. + if params.IsTestnet { + sdk.StateSetObject("is_testnet", "true") + } + return nil } -//go:wasmexport updateVkey -func updateVkey(input *string) *string { +// looksLikeBlockHash — minimal shape check for InitialBlockHash. Full +// 32-byte/hex validation happens implicitly at the first submitProof +// (the comparison vs parsed[0].ParentHash will fail loudly on any +// malformed value). +func looksLikeBlockHash(s string) bool { + if len(s) != 66 { + return false + } + if s[0] != '0' || (s[1] != 'x' && s[1] != 'X') { + return false + } + for i := 2; i < len(s); i++ { + c := s[i] + if !(c >= '0' && c <= '9') && !(c >= 'a' && c <= 'f') && !(c >= 'A' && c <= 'F') { + return false + } + } + return true +} + +// applySetExpectedElfHash — W4 Cluster B HIGH #22 Site 12: pins the SHA-256 +// of the SP1 ELF binary the prover is allowed to use. Prover startup reads +// this and fatal-exits on mismatch. Fund-affecting (wrong hash = bridge halt; +// malicious hash + compromised prover = attacker controls all ZK-verified +// Ethereum state). +// +// F1 fix: ROTATIONS are reachable ONLY via execute() after the 400K-block +// propose/execute timelock. The FIRST pin (below) is allowed instantly during +// deploy ramp-up — there is no trusted value to rotate FROM (the prover runs +// UNPINNED until this is set), so an instant first pin only ADDS protection. +// Previously this was an instant owner-only write for ALL changes (the hole), +// with a comment claiming a timelock that did not exist. +func applySetExpectedElfHash(payload string) { + var params struct { + Hash string `json:"hash"` + } + if err := json.Unmarshal([]byte(payload), ¶ms); err != nil { + ce.Abort(ce.ErrJson, "invalid JSON", "setExpectedElfHash") + } + sdk.StateSetObject(KeyExpectedElfHash, normalizeElfHash(params.Hash)) +} + +// setExpectedElfHash — FIRST-PIN ONLY (instant, owner-gated). Allows the +// operator to pin the SP1 ELF hash during deploy ramp-up without waiting the +// 400K-block timelock (the prover is UNPINNED until this runs, so an instant +// first pin only adds protection — there is nothing trusted to rotate from). +// Once set, this aborts and directs rotations through propose/execute, which +// IS timelocked. Mirrors init's one-shot for the vkey. +// +//go:wasmexport setExpectedElfHash +func setExpectedElfHash(input *string) *string { checkOwner() + if existing := sdk.StateGetObject(KeyExpectedElfHash); existing != nil && *existing != "" { + ce.Abort(ce.ErrState, "elf hash already set — use propose/execute (400K-block timelock) to rotate", "setExpectedElfHash") + } + if input == nil || *input == "" { + ce.Abort(ce.ErrInput, "setExpectedElfHash: empty payload", "setExpectedElfHash") + } + applySetExpectedElfHash(*input) + return nil +} + +// normalizeElfHash validates the 32-byte SHA-256 hex (optional 0x prefix) and +// returns the bare hex; aborts on bad shape. Shared by applySetExpectedElfHash +// and propose()-time payload validation (timelock.go). +func normalizeElfHash(h string) string { + if len(h) >= 2 && (h[:2] == "0x" || h[:2] == "0X") { + h = h[2:] + } + if len(h) != 64 { + ce.Abort(ce.ErrInput, "hash must be 32-byte hex (64 chars, optional 0x prefix)", "setExpectedElfHash") + } + for i := 0; i < len(h); i++ { + c := h[i] + if !(c >= '0' && c <= '9') && !(c >= 'a' && c <= 'f') && !(c >= 'A' && c <= 'F') { + ce.Abort(ce.ErrInput, "hash contains non-hex character", "setExpectedElfHash") + } + } + return h +} + +// applyUpdateVkey — rotates the Groth16 / SP1 verification key (the entire ZK +// trust root). +// +// F1 fix: NO LONGER a direct wasmexport. Reachable ONLY via execute() after +// the 400K-block propose/execute timelock (see timelock.go). Previously this +// was an instant owner-only write with a comment falsely claiming a timelock — +// an owner-key compromise could rotate the vkey and forge L1 state with zero +// reaction window. The timelock now gives watchers ~14 days to react. +func applyUpdateVkey(payload string) { var params struct { Groth16Vk string `json:"groth16_vk"` VkRoot string `json:"vk_root"` Sp1VkeyHash string `json:"sp1_vkey_hash"` } - if err := json.Unmarshal([]byte(*input), ¶ms); err != nil { + if err := json.Unmarshal([]byte(payload), ¶ms); err != nil { ce.Abort(ce.ErrJson, "invalid JSON", "updateVkey") } if params.Groth16Vk != "" { @@ -78,7 +276,6 @@ func updateVkey(input *string) *string { if params.Sp1VkeyHash != "" { sdk.StateSetObject(KeySp1VkeyHash, params.Sp1VkeyHash) } - return nil } // --- Permissionless proof submission --- @@ -128,16 +325,52 @@ func submitProof(input *string) *string { ce.Abort(ce.ErrTransaction, "proof verification failed", "submitProof") } - // 2. Decode publicValues to get proven block hash and number + // 2. Decode publicValues to get proven block hash, number, and chainId. pvBytes, err := hex.DecodeString(params.PublicValues) if err != nil { ce.Abort(ce.ErrInvalidHex, "invalid public_values hex", "submitProof") } - provenStateRoot, provenBlockHash, provenBlockNumber, perr := parseProvenFields(pvBytes) + // W4 Cluster B CRIT #6 Site 6: chainId is now returned from + // parseProvenFields (slot 11 of ProofOutputs). + provenStateRoot, provenBlockHash, provenBlockNumber, provenChainId, perr := parseProvenFields(pvBytes) if perr != nil { ce.CustomAbort(ce.Prepend(perr, "submitProof")) } + // W4 Cluster B CRIT #6 Site 6 ENFORCEMENT: reject any proof whose + // committed chainId does not match the chainId this verifier was bound + // to at init. Prior to this check the verifier parsed + stored + // provenChainId but never compared it, so a valid SP1 proof generated + // against the WRONG chain would have been accepted and its headers + // written as authoritative — defeating the entire chain-binding fix. + expectedChainPtr := sdk.StateGetObject(KeyExpectedChainId) + if expectedChainPtr == nil || *expectedChainPtr == "" { + ce.Abort(ce.ErrInitialization, "expected_chain_id not set; verifier must be initialized with expected_chain_id", "submitProof") + } + expectedChainId, ecErr := strconv.ParseUint(*expectedChainPtr, 10, 64) + if ecErr != nil { + ce.Abort(ce.ErrState, "stored expected_chain_id is not a valid uint64", "submitProof") + } + if provenChainId != expectedChainId { + ce.Abort(ce.ErrTransaction, "proof chainId ("+strconv.FormatUint(provenChainId, 10)+") does not match verifier's bound chainId ("+strconv.FormatUint(expectedChainId, 10)+")", "submitProof") + } + + // W4 Cluster B CRIT #25 — anchor check. + // Read the cross-batch anchor BEFORE parsing headers. Without an + // anchor (empty or zero hash), every prior submission state is + // untrusted and a forged "first batch" could inject arbitrary state. + // initContract MUST have set this; the InitContractParams. + // InitialBlockHash guard at init time prevents zero-anchor deploys. + anchorPtr := sdk.StateGetObject(KeyLastBlockHash) + if anchorPtr == nil || *anchorPtr == "" || *anchorPtr == "0x0000000000000000000000000000000000000000000000000000000000000000" { + ce.Abort(ce.ErrInitialization, "lastBlockHash anchor not set; call initContract with initial_block_hash", "submitProof") + } + anchorHex := *anchorPtr + // Strip 0x prefix to compare against the keccak hex (which is bare hex). + if len(anchorHex) >= 2 && (anchorHex[0:2] == "0x" || anchorHex[0:2] == "0X") { + anchorHex = anchorHex[2:] + } + // 3. Parse every header from its RLP and compute keccak hashes. // Trust flows: proof -> provenBlockHash -> last RLP keccak -> earlier RLPs // via parentHash chain -> all extracted fields from each RLP. @@ -149,6 +382,15 @@ func submitProof(input *string) *string { hashes[i] = sdk.Keccak256(h.RlpHex) } + // W4 Cluster B CRIT #25 — cross-batch parent check. + // The first header in this batch MUST extend the previously-stored + // chain at exactly `parsed[0].ParentHash == lastBlockHash`. This + // closes the "unbound prevHeader" gap that let any well-formed proof + // for ANY hash chain land regardless of the contract's prior history. + if hex.EncodeToString(parsed[0].ParentHash[:]) != anchorHex { + ce.Abort(ce.ErrTransaction, "parent hash of first header does not match stored anchor (lastBlockHash)", "submitProof") + } + // 4. Bind the last header's RLP and extracted fields to the proof's public inputs. if hashes[lastIdx] != provenBlockHash { ce.Abort(ce.ErrTransaction, "keccak256(last header RLP) != proven block hash", "submitProof") @@ -174,6 +416,10 @@ func submitProof(input *string) *string { } // 6. Sequential block-number check + store the RLP-extracted fields. + // W4 Cluster B Site 6 + §AW: every stored header gains a ChainId + // field populated from the proven slot-11 value. This is the + // authoritative ChainId for the EthBlockHeader the account-mapping + // contract reads downstream (CRIT #6 Site 2 ERC-20 path). lastHeight := getLastHeight() maxRetention := getMaxRetention() for i := range parsed { @@ -185,7 +431,7 @@ func submitProof(input *string) *string { ce.Abort(ce.ErrInput, "headers not sequential within batch", "submitProof") } - storeHeader(p.BlockNumber, p.StateRoot, p.TxRoot, p.RcptRoot, p.BaseFeePerGas, p.GasLimit, p.Timestamp) + storeHeader(p.BlockNumber, p.StateRoot, p.TxRoot, p.RcptRoot, p.BaseFeePerGas, p.GasLimit, p.Timestamp, provenChainId) lastHeight = p.BlockNumber if p.BlockNumber > maxRetention { @@ -194,6 +440,14 @@ func submitProof(input *string) *string { } sdk.StateSetObject(KeyLastHeight, strconv.FormatUint(lastHeight, 10)) + + // W4 Cluster B CRIT #25 — update anchor for the next batch. Use the + // hex of the last header's keccak so the next call's parsed[0]. + // ParentHash check has a canonical form to match against. Prepend 0x + // for consistency with the InitialBlockHash format (initContract + // validates 0x-prefixed input; stored value retains the prefix so + // off-chain tooling reads back identical to what was supplied). + sdk.StateSetObject(KeyLastBlockHash, "0x"+hashes[lastIdx]) return nil } @@ -210,6 +464,14 @@ func submitProof(input *string) *string { // 7 difficulty (16+ withdrawalsRoot/blob fields ignored) // 8 number +// parsedHeader — W4 Cluster B Site 4 + §AW disambiguation. +// ChainId field added but NOT populated by parseHeader (Ethereum RLP +// block headers have no chainId field — adding RLP extraction would be +// wrong). ChainId here is populated from parseProvenFields' return +// value (slot 11 of ProofOutputs) in submitProof, then forwarded into +// storeHeader. This struct's ChainId is informational only; the +// authoritative chainId is the function-local provenChainId that the +// submitProof loop passes into storeHeader. type parsedHeader struct { BlockNumber uint64 ParentHash [32]byte @@ -219,6 +481,7 @@ type parsedHeader struct { GasLimit uint64 Timestamp uint64 BaseFeePerGas uint64 + ChainId uint64 // populated post-parseProvenFields, NOT from RLP } func parseHeader(rlpHex string) parsedHeader { @@ -346,8 +609,22 @@ func readUintField(buf []byte, offset int) (int, uint64) { // --- Storage helpers --- -func storeHeader(blockNumber uint64, stateRoot, txRoot, rcptRoot [32]byte, baseFee, gasLimit, timestamp uint64) { - buf := make([]byte, 0, 128) +// HeaderVersionV1 + storeHeader — W4 Cluster B Site 4/6 unified layout. +// The serialized form MUST match the account-mapping EthBlockHeader +// serialization byte-for-byte (137 bytes total, version+128+chainId). +// account-mapping reads from this contract's KeyBlockPrefix via the +// VerifierContractIdKey indirection — any layout drift would silently +// misread chainId or baseFee. +// +// Layout (LOCKED, mirrors account-mapping/contract/blocklist/blocks.go): +// [version:1][blockNumber:8][stateRoot:32][txRoot:32][rcptRoot:32] +// [baseFee:8][gasLimit:8][timestamp:8][chainId:8] = 137 bytes. +const HeaderVersionV1 byte = 0x01 +const HeaderSerializedSize = 137 + +func storeHeader(blockNumber uint64, stateRoot, txRoot, rcptRoot [32]byte, baseFee, gasLimit, timestamp, chainId uint64) { + buf := make([]byte, 0, HeaderSerializedSize) + buf = append(buf, HeaderVersionV1) buf = appendUint64(buf, blockNumber) buf = append(buf, stateRoot[:]...) buf = append(buf, txRoot[:]...) @@ -355,6 +632,10 @@ func storeHeader(blockNumber uint64, stateRoot, txRoot, rcptRoot [32]byte, baseF buf = appendUint64(buf, baseFee) buf = appendUint64(buf, gasLimit) buf = appendUint64(buf, timestamp) + // W4 Cluster B Site 6: appended chainId; populated from the proven + // slot-11 value of ProofOutputs. account-mapping/blocklist/blocks.go + // DeserializeHeader reads this at byte offset 129-136. + buf = appendUint64(buf, chainId) sdk.StateSetObject(KeyBlockPrefix+strconv.FormatUint(blockNumber, 10), string(buf)) } @@ -394,20 +675,281 @@ func readUint64BE(b []byte) uint64 { uint64(b[4])<<24 | uint64(b[5])<<16 | uint64(b[6])<<8 | uint64(b[7]) } -// parseProvenFields extracts (executionStateRoot, executionBlockHash, executionBlockNumber) -// from the SP1 program's ABI-encoded ProofOutputs bytes. Returns a non-nil *ContractError -// on any structural problem. No SDK calls — pure function, safe to test under standard -// go test (contracterrors transitively imports sdk but its construction path doesn't -// exercise any wasmimport). -func parseProvenFields(pvBytes []byte) (stateRoot, blockHash string, blockNumber uint64, err *ce.ContractError) { +// parseProvenFields extracts (executionStateRoot, executionBlockHash, +// executionBlockNumber, chainId) from the SP1 program's ABI-encoded +// ProofOutputs bytes. Returns a non-nil *ContractError on any structural +// problem. No SDK calls — pure function, safe to test under standard +// go test (contracterrors transitively imports sdk but its construction +// path doesn't exercise any wasmimport). +// +// W4 Cluster B CRIT #6 Site 6 + S-B-v17-4 comment fix: +// - Extended return signature adds `chainId uint64` (slot 11). +// - Length check is split: legacy parsers see PvMinLen=288, chainId-aware +// callers MUST pass a buffer >= PvMinLenWithChainId=416. +// - chainId is read as uint64 big-endian from the LAST 8 bytes of the +// 32-byte ABI slot at offset PvFieldChainId (same pattern as +// blockNumber). See sp1-helios-magi/primitives/src/types.rs for the +// authoritative sol struct. +func parseProvenFields(pvBytes []byte) (stateRoot, blockHash string, blockNumber, chainId uint64, err *ce.ContractError) { if len(pvBytes) < PvMinLen { - return "", "", 0, ce.NewContractError(ce.ErrInput, "public_values too short for ABI fields") + return "", "", 0, 0, ce.NewContractError(ce.ErrInput, "public_values too short for ABI fields") } if readUint64BE(pvBytes[24:32]) != PvAbiOffset { - return "", "", 0, ce.NewContractError(ce.ErrInput, "unexpected ABI tuple offset (expected 32)") + return "", "", 0, 0, ce.NewContractError(ce.ErrInput, "unexpected ABI tuple offset (expected 32)") } stateRoot = hex.EncodeToString(pvBytes[PvFieldStateRoot : PvFieldStateRoot+32]) blockHash = hex.EncodeToString(pvBytes[PvFieldBlockHash : PvFieldBlockHash+32]) blockNumber = readUint64BE(pvBytes[PvFieldBlockNumber+24 : PvFieldBlockNumber+32]) + // W4 Cluster B Site 6: chainId is in slot 11 (PvFieldChainId=384). + // Require the full extended length before reading; legacy callers that + // only need stateRoot/blockHash/blockNumber are at line above and have + // already been served by the PvMinLen=288 check. + if len(pvBytes) < PvMinLenWithChainId { + return "", "", 0, 0, ce.NewContractError(ce.ErrInput, "public_values too short for chainId field") + } + chainId = readUint64BE(pvBytes[PvFieldChainId+24 : PvFieldChainId+32]) return } + +// ===== F1 fix: admin propose/execute timelock (was contract/timelock.go) ===== +const ( + KeyProposalCounter = "proposal_next_id" + ProposalPrefix = "pr-" + + // Fund-affecting timelock (~14 days at ~3s blocks) — same class as + // account-mapping's TimelockLong for setVault/setVerifierContract/vkey. + TimelockLong = uint64(400_000) + // Auto-expire window after execHeight (7 days) so stale proposals can be + // garbage-collected and cannot be executed long after their context. + ExpireWindowBlocks = uint64(201_600) + + StatusPending = uint8(0) + StatusAccepted = uint8(1) + StatusCancelled = uint8(2) + StatusExpired = uint8(3) +) + +type PendingProposal struct { + ProposalId uint64 `json:"proposal_id"` + Action string `json:"action"` + PayloadHash string `json:"payload_hash"` // keccak256 hex of Payload bytes + Proposer string `json:"proposer"` + QueueHeight uint64 `json:"queue_height"` + ExecHeight uint64 `json:"exec_height"` + ExpireHeight uint64 `json:"expire_height"` + Payload string `json:"payload"` // raw JSON for the action handler + Status uint8 `json:"status"` +} + +// timelockFor — only the two fund-affecting verifier actions are timelocked +// and propose-able. Anything else is rejected (no silent passthrough). +func timelockFor(action string) (uint64, bool) { + switch action { + case "updateVkey", "setExpectedElfHash": + return TimelockLong, true + default: + return 0, false + } +} + +func proposalKey(id uint64) string { + return ProposalPrefix + strconv.FormatUint(id, 10) +} + +// hashPayload — keccak256 of the raw payload bytes. sdk.Keccak256 takes a hex +// string, so the bytes are hex-encoded first. +func hashPayload(payload string) string { + return sdk.Keccak256(hex.EncodeToString([]byte(payload))) +} + +func nextProposalId() uint64 { + s := sdk.StateGetObject(KeyProposalCounter) + var id uint64 + if s != nil { + id, _ = strconv.ParseUint(*s, 10, 64) + } + if id == 0 { + id = 1 + } + sdk.StateSetObject(KeyProposalCounter, strconv.FormatUint(id+1, 10)) + return id +} + +func loadProposal(id uint64) *PendingProposal { + d := sdk.StateGetObject(proposalKey(id)) + if d == nil { + return nil + } + pp := &PendingProposal{} + if err := json.Unmarshal([]byte(*d), pp); err != nil { + return nil + } + return pp +} + +func storeProposal(pp *PendingProposal) { + b, _ := json.Marshal(pp) + sdk.StateSetObject(proposalKey(pp.ProposalId), string(b)) +} + +// propose — owner queues a fund-affecting action. Returns {proposal_id, exec_height}. +// +//go:wasmexport propose +func propose(input *string) *string { + checkOwner() + if input == nil || *input == "" { + ce.Abort(ce.ErrInput, "propose: empty payload", "propose") + } + var req struct { + Action string `json:"action"` + Payload string `json:"payload"` + } + if err := json.Unmarshal([]byte(*input), &req); err != nil { + ce.Abort(ce.ErrJson, "propose: invalid JSON", "propose") + } + timelock, ok := timelockFor(req.Action) + if !ok { + ce.Abort(ce.ErrInput, "propose: unknown or non-timelocked action", "propose") + } + // Validate the action payload up-front so a malformed proposal cannot + // sit for 14 days only to fail at execute time. + validateActionPayload(req.Action, req.Payload) + + bh := sdk.GetEnv().BlockHeight + id := nextProposalId() + execH := bh + timelock + pp := &PendingProposal{ + ProposalId: id, + Action: req.Action, + PayloadHash: hashPayload(req.Payload), + Proposer: sdk.GetEnv().Caller.String(), + QueueHeight: bh, + ExecHeight: execH, + ExpireHeight: execH + ExpireWindowBlocks, + Payload: req.Payload, + Status: StatusPending, + } + storeProposal(pp) + out := `{"proposal_id":` + strconv.FormatUint(id, 10) + `,"exec_height":` + strconv.FormatUint(execH, 10) + `}` + return &out +} + +// execute — owner enacts a proposal once its timelock has elapsed. +// +//go:wasmexport execute +func execute(input *string) *string { + checkOwner() + var req struct { + ProposalId uint64 `json:"proposal_id"` + } + if input == nil || json.Unmarshal([]byte(*input), &req) != nil { + ce.Abort(ce.ErrInput, "execute: invalid JSON", "execute") + } + pp := loadProposal(req.ProposalId) + if pp == nil { + ce.Abort(ce.ErrInput, "execute: proposal not found", "execute") + } + if pp.Status != StatusPending { + ce.Abort(ce.ErrState, "execute: proposal not pending", "execute") + } + bh := sdk.GetEnv().BlockHeight + if bh < pp.ExecHeight { + ce.Abort(ce.ErrState, "execute: timelock not elapsed", "execute") + } + if bh >= pp.ExpireHeight { + ce.Abort(ce.ErrState, "execute: proposal expired", "execute") + } + // Defense-in-depth: the stored payload must still hash to the committed + // PayloadHash (a storage tamper between propose and execute is rejected). + if hashPayload(pp.Payload) != pp.PayloadHash { + ce.Abort(ce.ErrState, "execute: payload hash mismatch", "execute") + } + switch pp.Action { + case "updateVkey": + applyUpdateVkey(pp.Payload) + case "setExpectedElfHash": + applySetExpectedElfHash(pp.Payload) + default: + ce.Abort(ce.ErrState, "execute: unknown action", "execute") + } + pp.Status = StatusAccepted + storeProposal(pp) + return nil +} + +// cancelProposal — owner aborts a pending proposal before execution. +// +//go:wasmexport cancelProposal +func cancelProposal(input *string) *string { + checkOwner() + var req struct { + ProposalId uint64 `json:"proposal_id"` + } + if input == nil || json.Unmarshal([]byte(*input), &req) != nil { + ce.Abort(ce.ErrInput, "cancelProposal: invalid JSON", "cancelProposal") + } + pp := loadProposal(req.ProposalId) + if pp == nil { + ce.Abort(ce.ErrInput, "cancelProposal: proposal not found", "cancelProposal") + } + if pp.Status != StatusPending { + ce.Abort(ce.ErrState, "cancelProposal: proposal not pending", "cancelProposal") + } + pp.Status = StatusCancelled + storeProposal(pp) + return nil +} + +// expireProposal — anyone may garbage-collect a proposal past its expire +// window (RC cost deters spam). Cannot enact anything; only marks expired. +// +//go:wasmexport expireProposal +func expireProposal(input *string) *string { + var req struct { + ProposalId uint64 `json:"proposal_id"` + } + if input == nil || json.Unmarshal([]byte(*input), &req) != nil { + ce.Abort(ce.ErrInput, "expireProposal: invalid JSON", "expireProposal") + } + pp := loadProposal(req.ProposalId) + if pp == nil { + ce.Abort(ce.ErrInput, "expireProposal: proposal not found", "expireProposal") + } + if pp.Status != StatusPending { + ce.Abort(ce.ErrState, "expireProposal: proposal not pending", "expireProposal") + } + if sdk.GetEnv().BlockHeight < pp.ExpireHeight { + ce.Abort(ce.ErrState, "expireProposal: not yet expired", "expireProposal") + } + pp.Status = StatusExpired + storeProposal(pp) + return nil +} + +// validateActionPayload runs the same shape checks the apply* handlers run, +// at propose() time, so a malformed payload is rejected immediately rather +// than after the 14-day wait. +func validateActionPayload(action, payload string) { + switch action { + case "updateVkey": + var p struct { + Groth16Vk string `json:"groth16_vk"` + VkRoot string `json:"vk_root"` + Sp1VkeyHash string `json:"sp1_vkey_hash"` + } + if err := json.Unmarshal([]byte(payload), &p); err != nil { + ce.Abort(ce.ErrJson, "propose(updateVkey): invalid payload JSON", "propose") + } + if p.Groth16Vk == "" && p.VkRoot == "" && p.Sp1VkeyHash == "" { + ce.Abort(ce.ErrInput, "propose(updateVkey): at least one of groth16_vk/vk_root/sp1_vkey_hash required", "propose") + } + case "setExpectedElfHash": + var p struct { + Hash string `json:"hash"` + } + if err := json.Unmarshal([]byte(payload), &p); err != nil { + ce.Abort(ce.ErrJson, "propose(setExpectedElfHash): invalid payload JSON", "propose") + } + normalizeElfHash(p.Hash) // aborts on bad shape + } +} diff --git a/contract/main_test.go b/contract/main_test.go index 55aa1a1..d6dceea 100644 --- a/contract/main_test.go +++ b/contract/main_test.go @@ -9,6 +9,23 @@ import ( // Real SP1-Helios v6.1.0 ABI-encoded ProofOutputs for Sepolia block 10764834. // Sourced from go-vsc-node modules/wasm/sdk/sp1_verifier_test.go. +// +// This is a legacy (pre-chainId) encoding: 13 ABI slots (416 bytes) whose +// final field is an EMPTY storageSlots[] dynamic array — slot 11 (byte 352) +// holds its offset pointer (0x160) and slot 12 (byte 384) holds its length +// (0). It predates W4 Cluster B Site 5's chainId extension and carries no +// chainId commitment. +// +// Watch the collision: the empty-array length slot lands exactly at +// PvFieldChainId (byte 384), and the corpus is exactly PvMinLenWithChainId +// (416) bytes. So parseProvenFields cannot reject this on length — it +// succeeds and reads chainId == 0 (the array length). Length alone cannot +// distinguish a legacy proof from a new one; only the SP1 verifying key +// can. A legacy proof is still rejected, downstream: submitProof compares +// the (zero) provenChainId against the verifier's bound chainId — forced +// non-zero at init — and aborts with ErrTransaction. When a rebuilt prover +// emits a real chainId, commit a new fixture and add a test asserting the +// expected non-zero value. const v6_1_0_PublicValuesHex = "00000000000000000000000000000000000000000000000000000000000000201024268bf088fa5770d276017f20a3fa4e0cc13c5f854b3a8b1f791cc1b85c3a00000000000000000000000000000000000000000000000000000000009af1e04577257aad51ec8b4519e0ba0546f2a032b2534f87f811a19b27b4d42278787d00000000000000000000000000000000000000000000000000000000009af220999525d0726b588e6cc9bd6841eb5393bc0b5137d980b30f8ed136efdd8a348e6efab94327bc2fd7eae04922c44be6be72f4e9f402410df10131be20870dc15e7cfbfa1dcd1490246a97443bbcce8e841e432d25df8f2ad1b6258e06441a3bdf0000000000000000000000000000000000000000000000000000000000a442224577257aad51ec8b4519e0ba0546f2a032b2534f87f811a19b27b4d42278787d4293e591071f6fb96e4a99a578693578bf4a9125c17b20abb845d3861c248ee000000000000000000000000000000000000000000000000000000000000001600000000000000000000000000000000000000000000000000000000000000000" const ( @@ -26,12 +43,24 @@ func mustDecodeHex(t *testing.T, s string) []byte { return b } -func TestParseProvenFields_HappyPath(t *testing.T) { +// Golden decode of parseProvenFields against a REAL alloy-abi-encoded +// ProofOutputs blob — the only parser test that does. The other parser +// tests plant bytes at the same offset constant they read back +// (e.g. pv[PvFieldBlockNumber+31] then read via PvFieldBlockNumber), so a +// wrong offset shifts write and read together and goes undetected. The +// known-good stateRoot/blockHash/blockNumber below are the sole non-circular +// anchor for those security-critical offsets. +// +// chainId is intentionally not asserted: this corpus predates the chainId +// extension, so PvFieldChainId reads the empty storageSlots[] length (0), +// not a committed chainId. There is no meaningful invariant to pin there — +// see the const block above. A real chainId needs a fixture from a rebuilt +// prover. +func TestParseProvenFields_DecodesRealV6_1_0Corpus(t *testing.T) { pv := mustDecodeHex(t, v6_1_0_PublicValuesHex) - - stateRoot, blockHash, blockNumber, err := parseProvenFields(pv) + stateRoot, blockHash, blockNumber, _, err := parseProvenFields(pv) if err != nil { - t.Fatalf("unexpected error: %v", err) + t.Fatalf("unexpected error parsing real corpus: %v", err) } if stateRoot != expectedStateRoot { t.Errorf("stateRoot = %q, want %q", stateRoot, expectedStateRoot) @@ -50,12 +79,17 @@ func TestParseProvenFields_TooShort(t *testing.T) { size int }{ {"empty", 0}, - {"one byte short of minimum", PvMinLen - 1}, + {"one byte short of legacy minimum", PvMinLen - 1}, {"only the offset prefix", 32}, + {"legacy boundary exact", PvMinLen}, // post-extension, this now triggers PvMinLenWithChainId rejection } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, _, _, err := parseProvenFields(make([]byte, tt.size)) + pv := make([]byte, tt.size) + if tt.size >= 32 { + pv[31] = byte(PvAbiOffset) // valid offset so length is the only failure mode + } + _, _, _, _, err := parseProvenFields(pv) if err == nil { t.Fatal("expected error for short input, got none") } @@ -67,13 +101,11 @@ func TestParseProvenFields_TooShort(t *testing.T) { } func TestParseProvenFields_WrongAbiOffset(t *testing.T) { - pv := mustDecodeHex(t, v6_1_0_PublicValuesHex) - - // Rewrite the offset (bytes [24:32]) from 0x20 to 0x40. Length stays the same, - // so the length check passes; only the offset check should reject this. + pv := make([]byte, PvMinLenWithChainId) + // Build a fully-sized buffer but with a wrong offset prefix. pv[31] = 0x40 - _, _, _, err := parseProvenFields(pv) + _, _, _, _, err := parseProvenFields(pv) if err == nil { t.Fatal("expected error for non-32 ABI offset, got none") } @@ -82,20 +114,47 @@ func TestParseProvenFields_WrongAbiOffset(t *testing.T) { } } -func TestParseProvenFields_OffsetExactlyAtBoundary(t *testing.T) { - // Public_values exactly PvMinLen bytes long with valid offset. - // Should succeed (boundary inclusion check). - pv := make([]byte, PvMinLen) +// W4 Cluster B Site 6: boundary check at exactly PvMinLenWithChainId +// with a plausibly-shaped offset+blockNumber+chainId. +func TestParseProvenFields_BoundaryWithChainId(t *testing.T) { + pv := make([]byte, PvMinLenWithChainId) pv[31] = byte(PvAbiOffset) // offset = 32 - - // Plant a recognizable block number at the right offset + // Plant a recognizable block number at the right offset. pv[PvFieldBlockNumber+31] = 0x42 + // Plant a chainId at slot 11 — last 8 bytes of [384:416]. + pv[PvFieldChainId+31] = 0x01 // chainId = 1 (mainnet) - _, _, blockNumber, err := parseProvenFields(pv) + _, _, blockNumber, chainId, err := parseProvenFields(pv) if err != nil { - t.Fatalf("unexpected error at PvMinLen boundary: %v", err) + t.Fatalf("unexpected error at PvMinLenWithChainId boundary: %v", err) } if blockNumber != 0x42 { t.Errorf("blockNumber = %d, want 0x42", blockNumber) } + if chainId != 1 { + t.Errorf("chainId = %d, want 1", chainId) + } +} + +// W4 Cluster B HIGH #22: sanity test on the looksLikeBlockHash shape +// check used by initContract to reject obviously-malformed anchors. +func TestLooksLikeBlockHash(t *testing.T) { + tests := []struct { + s string + want bool + }{ + {"0x0000000000000000000000000000000000000000000000000000000000000000", true}, + {"0xabcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", true}, + {"0xABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789", true}, + {"", false}, + {"0x", false}, + {"0xabcd", false}, // too short + {"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", false}, // no 0x prefix + {"0xZZZ_def0123456789abcdef0123456789abcdef0123456789abcdef0123456789", false}, // non-hex char + } + for _, tt := range tests { + if got := looksLikeBlockHash(tt.s); got != tt.want { + t.Errorf("looksLikeBlockHash(%q) = %v, want %v", tt.s, got, tt.want) + } + } }