diff --git a/README.md b/README.md index d2b62ee0..daea802e 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,43 @@ All fields are validated locally and strictly: private key format/length, `chain `OfflineSigner` guarantees that the **signing step itself** performs no network I/O — that guarantee is enforced in code and covered by tests. It **cannot** guarantee the security of anything around that step: the operating system on the air-gapped machine, the removable media used to move data across the gap, how or where the private key is generated and stored, or the physical transfer process. Those remain entirely your responsibility. Treat the offline machine as if it will eventually be compromised, and design your key-management practices accordingly. +## 🌉 Cross-Chain State Proof Verifier + +`whitechain-sdk/crypto` also exposes a local verifier for Ethereum [EIP-1186](https://eips.ethereum.org/EIPS/eip-1186) account and storage proofs — the Merkle Patricia Trie proofs a cross-chain bridge uses to prove "this account/storage slot had this value" against a specific chain state, without trusting the node that served the data. + +**Verified against a `stateRoot`, never a block hash.** A block hash identifies a block; it is not itself a trie root. Callers must supply the block's trusted `stateRoot` explicitly (e.g. from a header they've already verified elsewhere) — this module never fetches a block header and has no parameter slot for a block hash in its place. + +**Zero network dependencies, by construction, same as `OfflineSigner`:** verification runs entirely over the proof data you already have in hand (typically the result of an `eth_getProof` call made elsewhere). There is no RPC, HTTP, provider, transport, walletClient, or publicClient involved — never `fetch`, never a viem client action. This is enforced in code and covered by the same style of static + runtime network-stubbing tests as `OfflineSigner`. + +```ts +import { verifyEIP1186Proof, isValidStateProof } from 'whitechain-sdk/crypto' + +// `proof` is the (unmodified) result of an `eth_getProof` JSON-RPC call — +// fetched by your own RPC client, wherever that lives; this function never +// makes that call itself. +const proof = await publicClient.request({ + method: 'eth_getProof', + params: [address, [storageSlot], blockNumber], +}) + +// `stateRoot` must come from a source you trust independently — e.g. a +// block header you've already validated (its `stateRoot` field), not from +// the same untrusted RPC response you're trying to verify. +const result = verifyEIP1186Proof(trustedStateRoot, proof) + +if (result.valid) { + // result.account and result.storageProofs[i].result each carry + // `{ valid: true, kind: 'inclusion' | 'exclusion' }` — an exclusion + // result proves the account/slot is *absent*, which is just as + // meaningful to a bridge as a proven value. +} + +// Or, if you only need a pass/fail: +const ok = isValidStateProof(trustedStateRoot, proof) +``` + +`verifyAccountProof`/`verifyStorageProof` are also exported individually for verifying just one half of a proof (e.g. an account proof with no storage slots requested). All four functions return a structured result rather than a plain boolean by default — `{ valid: false, reason }` tells you *why* a proof failed (hash mismatch, value mismatch, malformed encoding, oversized input) rather than collapsing everything to `false`. + ## 🔌 Plugin System The SDK ships a first-class plugin architecture so community developers can extend the `WhitechainSDK` instance with custom namespaces — NFT marketplace helpers, lending calculators, analytics modules — without forking the core SDK or adding bloat to the core bundle. diff --git a/package.json b/package.json index a0710b1e..e154c703 100644 --- a/package.json +++ b/package.json @@ -82,8 +82,6 @@ "@vitest/coverage-v8": "^1.6.1", "typedoc": "^0.28.20", "typescript": "^5.4.0", - "ws": "^8.16.0", - "vitest": "^1.3.1" "vitest": "^1.3.1", "ws": "^8.16.0" } diff --git a/src/crypto/StateProver.ts b/src/crypto/StateProver.ts new file mode 100644 index 00000000..b8b24d9d --- /dev/null +++ b/src/crypto/StateProver.ts @@ -0,0 +1,420 @@ +/** + * Cross-chain state proof verifier. + * + * Validates Ethereum EIP-1186 account and storage proofs (Merkle Patricia + * Trie inclusion/exclusion proofs) locally against a caller-supplied, + * trusted `stateRoot`. This module performs no RPC, HTTP, provider, + * transport, or wallet/public client operations of any kind — it is pure + * cryptographic verification over data the caller already has in hand + * (typically the result of an `eth_getProof` call made elsewhere). + * + * A block *hash* identifies a block but is not itself a trie root — the + * caller must supply the block's trusted `stateRoot` explicitly (e.g. from + * a verified block header). This module never fetches a block header and + * never accepts a block hash in place of a state root. + * + * @see https://eips.ethereum.org/EIPS/eip-1186 + * @see https://ethereum.github.io/yellowpaper/paper.pdf (Appendix D — Trie) + */ + +import { fromRlp, getAddress, isHex, keccak256, toBytes, type Address, type Hex } from 'viem' +import { bytesToBigInt, decodeAccount, decodeHexPrefix, keyToNibbles, TrieEncodingError } from './rlp.js' + +/** Root hash of an empty Merkle Patricia Trie: `keccak256(RLP(''))`. */ +export const EMPTY_TRIE_ROOT: Hex = keccak256('0x80') + +/** `keccak256` of empty code: the `codeHash` of an account with no contract code. */ +export const EMPTY_CODE_HASH: Hex = keccak256('0x') + +/** A single EIP-1186 storage-slot proof entry. */ +export interface StorageProofInput { + /** Storage slot key. Left-padded to 32 bytes if shorter; must not exceed 32 bytes. */ + key: Hex + /** Claimed value at this slot. Compared canonically as an unsigned integer. */ + value: Hex | bigint | number + /** RLP-encoded storage-trie nodes, root first, as returned by `eth_getProof`. */ + proof: readonly Hex[] +} + +/** An EIP-1186 account proof, matching the shape returned by `eth_getProof`. */ +export interface AccountProofInput { + address: Address + balance: bigint | number + /** Account transaction count. Accepted as `number` (JSON-RPC quantity) or `bigint`. */ + nonce: number | bigint + codeHash: Hex + /** The account's storage-trie root. */ + storageHash: Hex + /** RLP-encoded account-trie nodes, root first, as returned by `eth_getProof`. */ + accountProof: readonly Hex[] + /** Optional per-slot storage proofs, verified against `storageHash`. */ + storageProof?: readonly StorageProofInput[] +} + +/** Structured result of verifying a single account or storage proof. */ +export type ProofVerificationResult = + | { valid: true; kind: 'inclusion' | 'exclusion' } + | { valid: false; reason: string } + +/** Structured result of verifying a full EIP-1186 proof (account + all storage slots). */ +export interface EIP1186VerificationResult { + valid: boolean + account: ProofVerificationResult + storageProofs: { key: Hex; result: ProofVerificationResult }[] +} + +// --------------------------------------------------------------------------- +// Denial-of-service guards. These bound proof size well above anything a real +// Ethereum trie can produce (max path depth is 64 nibbles; branch nodes are +// at most 17 * 32 bytes plus RLP overhead), so legitimate proofs are never +// rejected, while a maliciously oversized or deeply-nested input is. +// --------------------------------------------------------------------------- +const MAX_PROOF_NODES = 128 +const MAX_NODE_BYTES = 1024 +const MAX_TRAVERSAL_STEPS = 256 + +class ProofVerificationError extends Error { + constructor(message: string) { + super(message) + this.name = 'ProofVerificationError' + } +} + +function hexToBytesStrict(value: Hex, fieldName: string): Uint8Array { + if (!isHex(value)) { + throw new ProofVerificationError(`${fieldName} must be 0x-prefixed hex`) + } + try { + return toBytes(value) + } catch (error) { + throw new ProofVerificationError(`${fieldName} is not valid hex: ${(error as Error).message}`) + } +} + +/** Left-pads to 32 bytes; throws if the input already exceeds 32 bytes. */ +function normalizeHash32(value: Hex, fieldName: string): Uint8Array { + const bytes = hexToBytesStrict(value, fieldName) + if (bytes.length > 32) { + throw new ProofVerificationError(`${fieldName} exceeds 32 bytes`) + } + if (bytes.length === 32) return bytes + const padded = new Uint8Array(32) + padded.set(bytes, 32 - bytes.length) + return padded +} + +function toCanonicalBigInt(value: Hex | bigint | number, fieldName: string): bigint { + if (typeof value === 'bigint') { + if (value < 0n) throw new ProofVerificationError(`${fieldName} must not be negative`) + return value + } + if (typeof value === 'number') { + if (!Number.isInteger(value) || value < 0) { + throw new ProofVerificationError(`${fieldName} must be a non-negative integer`) + } + return BigInt(value) + } + if (typeof value === 'string') { + if (!isHex(value)) throw new ProofVerificationError(`${fieldName} must be 0x-prefixed hex`) + return bytesToBigInt(toBytes(value)) + } + throw new ProofVerificationError(`${fieldName} has an unsupported type`) +} + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false + } + return true +} + +function decodeProofNodes(proof: readonly Hex[], fieldName: string): Uint8Array[] { + if (!Array.isArray(proof)) { + throw new ProofVerificationError(`${fieldName} must be an array of hex-encoded trie nodes`) + } + if (proof.length > MAX_PROOF_NODES) { + throw new ProofVerificationError(`${fieldName} has ${proof.length} nodes, exceeding the maximum of ${MAX_PROOF_NODES}`) + } + return proof.map((node, index) => { + const bytes = hexToBytesStrict(node, `${fieldName}[${index}]`) + if (bytes.length > MAX_NODE_BYTES) { + throw new ProofVerificationError(`${fieldName}[${index}] is ${bytes.length} bytes, exceeding the maximum of ${MAX_NODE_BYTES}`) + } + return bytes + }) +} + +type NodeRef = { kind: 'hash'; hash: Uint8Array } | { kind: 'embedded'; node: unknown[] } + +function toNodeRef(child: unknown, context: string): NodeRef { + if (Array.isArray(child)) return { kind: 'embedded', node: child } + if (child instanceof Uint8Array) { + if (child.length === 0) { + throw new ProofVerificationError(`${context}: unexpected empty child reference`) + } + if (child.length !== 32) { + throw new ProofVerificationError(`${context}: non-embedded child reference must be exactly 32 bytes`) + } + return { kind: 'hash', hash: child } + } + throw new ProofVerificationError(`${context}: invalid child reference type`) +} + +function assertByteString(value: unknown, context: string): asserts value is Uint8Array { + if (!(value instanceof Uint8Array)) { + throw new ProofVerificationError(`${context} must be an RLP byte string, not a nested list`) + } +} + +function decodeNode(nodeBytes: Uint8Array): unknown[] { + let decoded: unknown + try { + decoded = fromRlp(nodeBytes, 'bytes') + } catch (error) { + throw new ProofVerificationError(`proof node is not valid RLP: ${(error as Error).message}`) + } + if (!Array.isArray(decoded)) { + throw new ProofVerificationError('proof node must RLP-decode to a list') + } + return decoded +} + +type TraversalResult = { outcome: 'included'; value: Uint8Array } | { outcome: 'excluded' } + +/** + * Walks a Merkle Patricia Trie proof from `rootHash` along `nibbles`, + * verifying every hash link as it goes. Returns the raw leaf value on + * inclusion, or an `excluded` outcome for a well-formed non-membership + * proof. Throws {@link ProofVerificationError} for any structural defect + * (hash mismatch, malformed RLP, oversized/cyclic input) — the caller is + * responsible for turning that into a `{ valid: false, reason }` result. + */ +function traverseTrie(rootHash: Uint8Array, proofNodes: Uint8Array[], nibbles: number[]): TraversalResult { + let ref: NodeRef = { kind: 'hash', hash: rootHash } + let nibbleIndex = 0 + const cursor = { index: 0 } + + for (let step = 0; ; step++) { + if (step > MAX_TRAVERSAL_STEPS) { + throw new ProofVerificationError('proof traversal exceeded the maximum allowed number of steps') + } + + let decoded: unknown[] + if (ref.kind === 'embedded') { + decoded = ref.node + } else { + if (cursor.index >= proofNodes.length) { + // An empty trie has no backing node at all; its root is a fixed + // constant, so an empty proof array against that root is a valid, + // trivial exclusion proof rather than a malformed one. + if (bytesEqual(ref.hash, toBytes(EMPTY_TRIE_ROOT))) { + return { outcome: 'excluded' } + } + throw new ProofVerificationError('proof ended before the key path was resolved') + } + const nodeBytes = proofNodes[cursor.index] + cursor.index += 1 + const actualHash = keccak256(nodeBytes, 'bytes') + if (!bytesEqual(actualHash, ref.hash)) { + throw new ProofVerificationError('proof node hash does not match the expected reference') + } + decoded = decodeNode(nodeBytes) + } + + if (decoded.length === 17) { + if (nibbleIndex === nibbles.length) { + const value = decoded[16] + assertByteString(value, 'branch value slot') + return value.length === 0 ? { outcome: 'excluded' } : { outcome: 'included', value } + } + const nibble = nibbles[nibbleIndex] + const child = decoded[nibble] + if (child instanceof Uint8Array && child.length === 0) { + return { outcome: 'excluded' } + } + nibbleIndex += 1 + ref = toNodeRef(child, `branch child [${nibble}]`) + continue + } + + if (decoded.length === 2) { + const encodedPath = decoded[0] + assertByteString(encodedPath, 'node path') + const { nibbles: pathNibbles, isLeaf } = decodeHexPrefix(encodedPath) + + const remaining = nibbles.length - nibbleIndex + if (pathNibbles.length > remaining) { + return { outcome: 'excluded' } + } + for (let i = 0; i < pathNibbles.length; i++) { + if (nibbles[nibbleIndex + i] !== pathNibbles[i]) { + return { outcome: 'excluded' } + } + } + nibbleIndex += pathNibbles.length + + const value = decoded[1] + if (isLeaf) { + if (nibbleIndex !== nibbles.length) { + return { outcome: 'excluded' } + } + assertByteString(value, 'leaf value') + return { outcome: 'included', value } + } + + // Extension node: an extension always leads to a branch, so a key + // path that ends exactly at the extension boundary is a prefix of + // some other stored key, not a stored key itself. + if (nibbleIndex === nibbles.length) { + return { outcome: 'excluded' } + } + ref = toNodeRef(value, 'extension child') + continue + } + + throw new ProofVerificationError(`proof node has invalid arity: expected 2 or 17 items, got ${decoded.length}`) + } +} + +function toResult( + fn: () => TraversalResult, + onIncluded: (value: Uint8Array) => ProofVerificationResult, + onExcluded: () => ProofVerificationResult = () => ({ valid: true, kind: 'exclusion' }), +): ProofVerificationResult { + try { + const result = fn() + if (result.outcome === 'excluded') return onExcluded() + return onIncluded(result.value) + } catch (error) { + if (error instanceof ProofVerificationError || error instanceof TrieEncodingError) { + return { valid: false, reason: error.message } + } + throw error + } +} + +/** + * Verifies an EIP-1186 account proof against a trusted `stateRoot`. + * + * On inclusion, the decoded on-chain `nonce`/`balance`/`codeHash`/`storageHash` + * must exactly match the corresponding fields on `proof` — a mismatch is + * reported as `{ valid: false }`, never silently ignored. On exclusion, the + * proof establishes only that no account exists at `proof.address`; the + * `balance`/`nonce`/`codeHash`/`storageHash` fields are not evaluated. + */ +export function verifyAccountProof(stateRoot: Hex, proof: AccountProofInput): ProofVerificationResult { + return toResult( + () => { + const rootBytes = normalizeHash32(stateRoot, 'stateRoot') + + let address: Address + try { + address = getAddress(proof.address) + } catch { + throw new ProofVerificationError(`invalid account address: ${proof.address}`) + } + const keyHash = keccak256(toBytes(address), 'bytes') + const nibbles = keyToNibbles(keyHash) + + const proofNodes = decodeProofNodes(proof.accountProof, 'accountProof') + return traverseTrie(rootBytes, proofNodes, nibbles) + }, + (value) => { + const decoded = decodeAccount(value) + const claimedNonce = toCanonicalBigInt(proof.nonce, 'nonce') + const claimedBalance = toCanonicalBigInt(proof.balance, 'balance') + const claimedCodeHash = normalizeHash32(proof.codeHash, 'codeHash') + const claimedStorageHash = normalizeHash32(proof.storageHash, 'storageHash') + + if (decoded.nonce !== claimedNonce) { + return { valid: false, reason: `nonce mismatch: proof has ${decoded.nonce}, claimed ${claimedNonce}` } + } + if (decoded.balance !== claimedBalance) { + return { valid: false, reason: `balance mismatch: proof has ${decoded.balance}, claimed ${claimedBalance}` } + } + if (!bytesEqual(toBytes(decoded.codeHash), claimedCodeHash)) { + return { valid: false, reason: 'codeHash mismatch between proof and claimed account' } + } + if (!bytesEqual(toBytes(decoded.storageRoot), claimedStorageHash)) { + return { valid: false, reason: 'storageHash mismatch between proof and claimed account' } + } + return { valid: true, kind: 'inclusion' } + }, + ) +} + +/** + * Verifies a single EIP-1186 storage-slot proof against a trusted + * `storageRoot` (the account's verified `storageHash`). + * + * `input.key` is normalized to a canonical 32-byte big-endian slot before + * hashing. Values are compared as canonical unsigned integers — leading + * zero bytes and RLP's minimal-integer encoding never cause a false + * mismatch. A missing slot must be claimed as value `0`; any other claimed + * value against a proof of absence is reported as invalid. + */ +export function verifyStorageProof(storageRoot: Hex, input: StorageProofInput): ProofVerificationResult { + return toResult( + () => { + const rootBytes = normalizeHash32(storageRoot, 'storageRoot') + const slotBytes = normalizeHash32(input.key, 'storage key') + const keyHash = keccak256(slotBytes, 'bytes') + const nibbles = keyToNibbles(keyHash) + const proofNodes = decodeProofNodes(input.proof, 'storageProof.proof') + return traverseTrie(rootBytes, proofNodes, nibbles) + }, + (value) => { + let innerBytes: unknown + try { + innerBytes = fromRlp(value, 'bytes') + } catch (error) { + return { valid: false, reason: `storage value is not valid RLP: ${(error as Error).message}` } + } + if (!(innerBytes instanceof Uint8Array)) { + return { valid: false, reason: 'storage value must decode to a single RLP byte string' } + } + const decodedValue = bytesToBigInt(innerBytes) + const claimedValue = toCanonicalBigInt(input.value, 'storage value') + if (decodedValue !== claimedValue) { + return { valid: false, reason: `storage value mismatch: proof has ${decodedValue}, claimed ${claimedValue}` } + } + return { valid: true, kind: 'inclusion' } + }, + () => { + // An absent slot is defined to hold value 0 — a proof of absence + // combined with a non-zero claimed value is a contradiction, not a + // valid exclusion. + const claimedValue = toCanonicalBigInt(input.value, 'storage value') + if (claimedValue !== 0n) { + return { valid: false, reason: `proof shows slot is absent (value 0), but ${claimedValue} was claimed` } + } + return { valid: true, kind: 'exclusion' } + }, + ) +} + +/** + * Verifies a full EIP-1186 proof — the account proof against `stateRoot`, + * and every `storageProof` entry against the account's verified + * `storageHash` — in one call. Mirrors the exact response shape of + * `eth_getProof` (and viem's `getProof` action), so a raw RPC response can + * be passed through unmodified. + */ +export function verifyEIP1186Proof(stateRoot: Hex, proof: AccountProofInput): EIP1186VerificationResult { + const account = verifyAccountProof(stateRoot, proof) + const storageProofs = (proof.storageProof ?? []).map((entry) => ({ + key: entry.key, + result: verifyStorageProof(proof.storageHash, entry), + })) + const valid = account.valid && storageProofs.every((entry) => entry.result.valid) + return { valid, account, storageProofs } +} + +/** + * Boolean convenience wrapper around {@link verifyEIP1186Proof} for callers + * who only need a pass/fail result. + */ +export function isValidStateProof(stateRoot: Hex, proof: AccountProofInput): boolean { + return verifyEIP1186Proof(stateRoot, proof).valid +} diff --git a/src/crypto/index.ts b/src/crypto/index.ts index fd3f364b..2c186d7a 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -1,2 +1,15 @@ export { sign, verify, recoverPublicKey, getPublicKey, getActiveBackendName } from './signer.js' export type { Signature, SignerBackend } from './types.js' + +export { + verifyAccountProof, + verifyStorageProof, + verifyEIP1186Proof, + isValidStateProof, + EMPTY_TRIE_ROOT, + EMPTY_CODE_HASH, + type AccountProofInput, + type StorageProofInput, + type ProofVerificationResult, + type EIP1186VerificationResult, +} from './StateProver.js' diff --git a/src/crypto/rlp.ts b/src/crypto/rlp.ts new file mode 100644 index 00000000..6ee8be4d --- /dev/null +++ b/src/crypto/rlp.ts @@ -0,0 +1,135 @@ +/** + * Merkle Patricia Trie encoding primitives used to verify Ethereum EIP-1186 + * state proofs (see `StateProver.ts`). + * + * These are the encoding-level building blocks only — nibble paths, the + * compact "hex-prefix" scheme used for leaf/extension node paths, and the + * RLP account structure. Trie traversal, hash verification, and the public + * verification API live in `StateProver.ts`. + * + * @see https://ethereum.github.io/yellowpaper/paper.pdf (Appendix D — Trie) + * @see https://eips.ethereum.org/EIPS/eip-1186 + */ + +import { fromRlp, type Hex } from 'viem' + +/** Thrown for structurally invalid trie encodings. Callers should treat this as "malformed proof". */ +export class TrieEncodingError extends Error { + constructor(message: string) { + super(message) + this.name = 'TrieEncodingError' + } +} + +/** Splits raw key bytes into an array of 4-bit nibbles (2 nibbles per byte, high nibble first). */ +export function keyToNibbles(key: Uint8Array): number[] { + const nibbles = new Array(key.length * 2) + for (let i = 0; i < key.length; i++) { + nibbles[i * 2] = key[i] >> 4 + nibbles[i * 2 + 1] = key[i] & 0x0f + } + return nibbles +} + +/** A decoded compact ("hex-prefix") path, as used by leaf and extension node encodings. */ +export interface HexPrefixPath { + nibbles: number[] + isLeaf: boolean +} + +/** + * Decodes the compact hex-prefix encoding used for leaf/extension node paths. + * The first nibble of the first byte carries two flag bits: bit 1 (value 2) + * marks a leaf vs. extension node, bit 0 (value 1) marks an odd nibble count. + * An odd-length path's first real nibble is packed into the low nibble of + * the flag byte; an even-length path has that low nibble as padding (0). + */ +export function decodeHexPrefix(encoded: Uint8Array): HexPrefixPath { + if (encoded.length === 0) { + throw new TrieEncodingError('compact-encoded path must not be empty') + } + + const flag = encoded[0] >> 4 + if (flag > 3) { + throw new TrieEncodingError(`invalid hex-prefix flag nibble: ${flag}`) + } + + const isLeaf = (flag & 0b10) !== 0 + const isOdd = (flag & 0b01) !== 0 + + const nibbles: number[] = [] + if (isOdd) { + nibbles.push(encoded[0] & 0x0f) + } else if ((encoded[0] & 0x0f) !== 0) { + throw new TrieEncodingError('even-length hex-prefix path must have a zero padding nibble') + } + + for (let i = 1; i < encoded.length; i++) { + nibbles.push(encoded[i] >> 4, encoded[i] & 0x0f) + } + + return { nibbles, isLeaf } +} + +/** A decoded Ethereum account, as stored at an account-trie leaf. */ +export interface DecodedAccount { + nonce: bigint + balance: bigint + storageRoot: Hex + codeHash: Hex +} + +/** + * Converts big-endian bytes to an unsigned bigint, treating a zero-length + * array as `0`. Unlike viem's `bytesToBigInt`/`hexToBigInt` (which throw on + * an empty input via `BigInt('0x')`), this must succeed on empty input: + * RLP encodes the integer `0` as a zero-length byte string, which is a + * routine, valid value in both account and storage trie leaves. + */ +export function bytesToBigInt(bytes: Uint8Array): bigint { + if (bytes.length === 0) return 0n + let hex = '0x' + for (const byte of bytes) hex += byte.toString(16).padStart(2, '0') + return BigInt(hex) +} + +function bytesToHash32(bytes: Uint8Array, fieldName: string): Hex { + if (bytes.length > 32) { + throw new TrieEncodingError(`${fieldName} exceeds 32 bytes`) + } + let hex = '0x' + for (let i = 0; i < 32 - bytes.length; i++) hex += '00' + for (const byte of bytes) hex += byte.toString(16).padStart(2, '0') + return hex as Hex +} + +/** + * Decodes an RLP-encoded account leaf value into its four fields. + * @throws {TrieEncodingError} if the RLP does not decode to a 4-item list of byte strings. + */ +export function decodeAccount(rlpAccount: Uint8Array): DecodedAccount { + let decoded: unknown + try { + decoded = fromRlp(rlpAccount, 'bytes') + } catch (error) { + throw new TrieEncodingError(`account value is not valid RLP: ${(error as Error).message}`) + } + + if (!Array.isArray(decoded) || decoded.length !== 4) { + throw new TrieEncodingError('account value must be an RLP list of 4 items [nonce, balance, storageRoot, codeHash]') + } + + const [nonceBytes, balanceBytes, storageRootBytes, codeHashBytes] = decoded + for (const item of [nonceBytes, balanceBytes, storageRootBytes, codeHashBytes]) { + if (!(item instanceof Uint8Array)) { + throw new TrieEncodingError('account fields must be RLP byte strings, not nested lists') + } + } + + return { + nonce: bytesToBigInt(nonceBytes as Uint8Array), + balance: bytesToBigInt(balanceBytes as Uint8Array), + storageRoot: bytesToHash32(storageRootBytes as Uint8Array, 'storageRoot'), + codeHash: bytesToHash32(codeHashBytes as Uint8Array, 'codeHash'), + } +} diff --git a/src/index.ts b/src/index.ts index 9fc6509c..e8b53922 100644 --- a/src/index.ts +++ b/src/index.ts @@ -145,6 +145,23 @@ export { type SignerBackend, } from './crypto/index.js' +// --------------------------------------------------------------------------- +// Cross-chain state proof verifier (EIP-1186 account/storage proofs) +// --------------------------------------------------------------------------- + +export { + verifyAccountProof, + verifyStorageProof, + verifyEIP1186Proof, + isValidStateProof, + EMPTY_TRIE_ROOT, + EMPTY_CODE_HASH, + type AccountProofInput, + type StorageProofInput, + type ProofVerificationResult, + type EIP1186VerificationResult, +} from './crypto/StateProver.js' + export { Simulator } from './services/Simulator.js' export type { SimulationResult, SimulationOptions, TransferEvent, StateOverrides } from './types/simulation.js' // --------------------------------------------------------------------------- diff --git a/tests/crypto/StateProver.no-network.test.ts b/tests/crypto/StateProver.no-network.test.ts new file mode 100644 index 00000000..4a75e1e0 --- /dev/null +++ b/tests/crypto/StateProver.no-network.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { keccak256, toBytes, toHex, type Hex } from 'viem' +import { verifyAccountProof, verifyStorageProof, EMPTY_CODE_HASH, EMPTY_TRIE_ROOT } from '../../src/crypto/StateProver.js' +import { buildSingleLeafTrie, encodeAccountRlp, keyToNibblesLocal } from './trieFixtures.js' + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..') +const cryptoSrcFiles = [ + join(repoRoot, 'src', 'crypto', 'StateProver.ts'), + join(repoRoot, 'src', 'crypto', 'rlp.ts'), +] + +/** Strips comments so the scan only matches actual code, not this module's own JSDoc. */ +function stripComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '') +} + +const FORBIDDEN_PATTERNS: Array<{ name: string; pattern: RegExp }> = [ + { name: 'fetch(', pattern: /\bfetch\s*\(/ }, + { name: 'http(', pattern: /\bhttp\s*\(/ }, + { name: 'webSocket(', pattern: /\bwebSocket\s*\(/ }, + { name: 'WebSocket', pattern: /\bnew WebSocket\b/ }, + { name: 'XMLHttpRequest', pattern: /XMLHttpRequest/ }, + { name: 'createPublicClient', pattern: /createPublicClient/ }, + { name: 'createWalletClient', pattern: /createWalletClient/ }, + { name: 'createTransport', pattern: /createTransport/ }, + { name: 'getProof(', pattern: /\bgetProof\s*\(/ }, + { name: 'getBlock(', pattern: /\bgetBlock\s*\(/ }, + { name: 'node:http(s) import', pattern: /from ['"]node:https?['"]/ }, +] + +/** + * Issue: "Build Cross-Chain State Proof Verifier Utility". StateProver must + * validate EIP-1186 proofs entirely locally against a caller-supplied + * stateRoot — no RPC, HTTP, provider, transport, or client of any kind, and + * never a block-hash-to-header fetch in place of a trusted stateRoot. + */ +describe('StateProver has no network operations (local-only guarantee)', () => { + it('never references RPC/network/client symbols in src/crypto/StateProver.ts or rlp.ts', () => { + for (const file of cryptoSrcFiles) { + const code = stripComments(readFileSync(file, 'utf-8')) + for (const { name, pattern } of FORBIDDEN_PATTERNS) { + expect(code, `${file} should not reference "${name}" in code`).not.toMatch(pattern) + } + } + }) + + it('never imports a provider/transport/network/client module', () => { + for (const file of cryptoSrcFiles) { + const content = readFileSync(file, 'utf-8') + expect(content, `${file} should not import a network provider`).not.toMatch( + /from ['"].*\/(providers|network)\//, + ) + } + }) + + it('never imports axios or another HTTP client dependency', () => { + for (const file of cryptoSrcFiles) { + const content = readFileSync(file, 'utf-8') + expect(content, `${file} should not import an HTTP client`).not.toMatch( + /from ['"](axios|node-fetch|undici|ethers)['"]/, + ) + } + }) + + it('only imports from viem and its own sibling module', () => { + for (const file of cryptoSrcFiles) { + const content = readFileSync(file, 'utf-8') + const importLines = content.match(/^import .+$/gm) ?? [] + for (const line of importLines) { + expect(line).toMatch(/from ['"](viem|\.\/rlp\.js)['"]/) + } + } + }) +}) + +describe('StateProver never performs network I/O at runtime', () => { + const originalFetch = globalThis.fetch + const originalWebSocket = (globalThis as { WebSocket?: unknown }).WebSocket + const originalXHR = (globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest + + afterEach(() => { + globalThis.fetch = originalFetch + ;(globalThis as { WebSocket?: unknown }).WebSocket = originalWebSocket + ;(globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest = originalXHR + }) + + it('verifies a proof successfully with fetch, WebSocket, and XMLHttpRequest all replaced with throwing spies', () => { + globalThis.fetch = (() => { + throw new Error('network access attempted via fetch()') + }) as typeof fetch + ;(globalThis as { WebSocket?: unknown }).WebSocket = class { + constructor() { + throw new Error('network access attempted via WebSocket') + } + } + ;(globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest = class { + constructor() { + throw new Error('network access attempted via XMLHttpRequest') + } + } + + const address = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' as const + const nonce = 1n + const balance = 0n + const nibbles = keyToNibblesLocal(keccak256(toBytes(address), 'bytes')) + const accountRlp = encodeAccountRlp({ nonce, balance, storageRoot: EMPTY_TRIE_ROOT, codeHash: EMPTY_CODE_HASH }) + const { root, proof } = buildSingleLeafTrie(nibbles, accountRlp) + + const result = verifyAccountProof(root, { + address, + nonce, + balance, + codeHash: EMPTY_CODE_HASH, + storageHash: EMPTY_TRIE_ROOT, + accountProof: proof, + }) + expect(result).toEqual({ valid: true, kind: 'inclusion' }) + + const slot32 = toHex(1n, { size: 32 }) as Hex + const storageResult = verifyStorageProof(EMPTY_TRIE_ROOT, { key: slot32, value: 0n, proof: [] }) + expect(storageResult).toEqual({ valid: true, kind: 'exclusion' }) + }) + + it('requires no provider, client, or network argument to call', () => { + // verifyAccountProof/verifyStorageProof take only plain data (a hex + // stateRoot and a plain proof object) — there is no parameter slot for + // a provider/client/RPC config, so this is also enforced at the type level. + expect(verifyAccountProof.length).toBe(2) + expect(verifyStorageProof.length).toBe(2) + }) +}) diff --git a/tests/crypto/StateProver.test.ts b/tests/crypto/StateProver.test.ts new file mode 100644 index 00000000..59eae5c3 --- /dev/null +++ b/tests/crypto/StateProver.test.ts @@ -0,0 +1,273 @@ +import { describe, it, expect } from 'vitest' +import { keccak256, toBytes, toHex, toRlp, type Hex } from 'viem' +import { + verifyAccountProof, + verifyStorageProof, + verifyEIP1186Proof, + isValidStateProof, + EMPTY_TRIE_ROOT, + EMPTY_CODE_HASH, + type AccountProofInput, +} from '../../src/crypto/StateProver.js' +import { + buildSingleLeafTrie, + buildExtensionBranchTrie, + buildEmbeddedLeafTrie, + encodeAccountRlp, + keyToNibblesLocal, + minimalBytes, +} from './trieFixtures.js' + +// Well-known, publicly documented test addresses (Hardhat/Anvil default +// accounts #0 and #1) — already used elsewhere in this repo's fixtures +// (tests/security/no-network.test.ts), reused here for consistency. +const ADDRESS_A = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' as const +const ADDRESS_B = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' as const + +function accountKeyNibbles(address: Hex): number[] { + return keyToNibblesLocal(keccak256(toBytes(address), 'bytes')) +} + +function storageKeyNibbles(slot32: Hex): number[] { + return keyToNibblesLocal(keccak256(toBytes(slot32), 'bytes')) +} + +describe('verifyAccountProof — single-leaf trie (root is the leaf)', () => { + const nonce = 5n + const balance = 1_000_000_000_000_000_000n + const codeHash = EMPTY_CODE_HASH + const storageHash = EMPTY_TRIE_ROOT + const accountRlp = encodeAccountRlp({ nonce, balance, storageRoot: storageHash, codeHash }) + const nibbles = accountKeyNibbles(ADDRESS_A) + const { root, proof } = buildSingleLeafTrie(nibbles, accountRlp) + + const baseProof: AccountProofInput = { + address: ADDRESS_A, + nonce, + balance, + codeHash, + storageHash, + accountProof: proof, + } + + it('verifies a valid inclusion proof', () => { + expect(verifyAccountProof(root, baseProof)).toEqual({ valid: true, kind: 'inclusion' }) + }) + + it('accepts nonce as a plain number as well as bigint', () => { + const result = verifyAccountProof(root, { ...baseProof, nonce: Number(nonce) }) + expect(result).toEqual({ valid: true, kind: 'inclusion' }) + }) + + it('rejects a tampered state root', () => { + const wrongRoot = (root.slice(0, -1) + (root.endsWith('0') ? '1' : '0')) as Hex + const result = verifyAccountProof(wrongRoot, baseProof) + expect(result.valid).toBe(false) + }) + + it('rejects a tampered proof node byte', () => { + const tampered = toBytes(proof[0]) + tampered[10] ^= 0xff + const result = verifyAccountProof(root, { ...baseProof, accountProof: [toHex(tampered)] }) + expect(result.valid).toBe(false) + }) + + it.each([ + ['nonce', { nonce: nonce + 1n }], + ['balance', { balance: balance + 1n }], + ['codeHash', { codeHash: EMPTY_TRIE_ROOT }], // any other valid-looking 32-byte hash + ['storageHash', { storageHash: EMPTY_CODE_HASH }], + ])('rejects a claimed %s that does not match the proven account', (field, override) => { + const result = verifyAccountProof(root, { ...baseProof, ...override }) + expect(result.valid).toBe(false) + expect((result as { reason: string }).reason).toMatch(new RegExp(field)) + }) + + it('produces a valid exclusion proof for a different address against the same trie', () => { + const result = verifyAccountProof(root, { ...baseProof, address: ADDRESS_B }) + expect(result).toEqual({ valid: true, kind: 'exclusion' }) + }) + + it('reports a malformed (not merely excluded) proof for an empty accountProof against a non-empty root', () => { + const result = verifyAccountProof(root, { ...baseProof, accountProof: [] }) + expect(result.valid).toBe(false) + }) + + it('rejects a proof node exceeding the maximum node size', () => { + const oversized = toHex(new Uint8Array(2000).fill(0xab)) + const result = verifyAccountProof(root, { ...baseProof, accountProof: [oversized] }) + expect(result.valid).toBe(false) + expect((result as { reason: string }).reason).toMatch(/exceeding the maximum/) + }) + + it('rejects a proof array exceeding the maximum node count', () => { + const many = Array.from({ length: 200 }, () => proof[0]) + const result = verifyAccountProof(root, { ...baseProof, accountProof: many }) + expect(result.valid).toBe(false) + expect((result as { reason: string }).reason).toMatch(/exceeding the maximum/) + }) +}) + +describe('verifyStorageProof — extension + branch trie', () => { + const slot32 = toHex(1n, { size: 32 }) + const targetValue = 42n + const nibbles = storageKeyNibbles(slot32) + const branchDepth = 4 + const storageLeafValue = toRlp(toHex(minimalBytes(targetValue)), 'bytes') + const { root, proof } = buildExtensionBranchTrie(nibbles, branchDepth, storageLeafValue) + + it('verifies a valid inclusion proof', () => { + const result = verifyStorageProof(root, { key: slot32, value: targetValue, proof }) + expect(result).toEqual({ valid: true, kind: 'inclusion' }) + }) + + it('treats a canonically-equal but differently-formatted claimed value as a match (leading zeros)', () => { + const paddedValue = toHex(targetValue, { size: 32 }) // same integer, 32-byte hex form + const result = verifyStorageProof(root, { key: slot32, value: paddedValue, proof }) + expect(result).toEqual({ valid: true, kind: 'inclusion' }) + }) + + it('accepts a plain number claimed value', () => { + const result = verifyStorageProof(root, { key: slot32, value: Number(targetValue), proof }) + expect(result).toEqual({ valid: true, kind: 'inclusion' }) + }) + + it('rejects a mismatched claimed value', () => { + const result = verifyStorageProof(root, { key: slot32, value: targetValue + 1n, proof }) + expect(result.valid).toBe(false) + }) + + it('rejects a tampered branch node (breaks the hash chain)', () => { + const tamperedBranch = toBytes(proof[1]) + tamperedBranch[5] ^= 0xff + const tamperedProof = [proof[0], toHex(tamperedBranch), proof[2]] + const result = verifyStorageProof(root, { key: slot32, value: targetValue, proof: tamperedProof }) + expect(result.valid).toBe(false) + }) + + it('normalizes a shorter-than-32-byte storage key before hashing', () => { + // slot32 is `1n` left-padded to 32 bytes; an unpadded `0x01` must hash identically. + const result = verifyStorageProof(root, { key: '0x01', value: targetValue, proof }) + expect(result).toEqual({ valid: true, kind: 'inclusion' }) + }) + + it('produces a valid exclusion proof for a key landing on an empty branch slot', () => { + // Brute-force a second real key whose hash shares the extension's nibble + // prefix (so it walks the same extension into the same branch) but whose + // nibble at the branch depth lands on neither the target's nor the + // sibling placeholder's occupied slot — guaranteeing a clean, unambiguous + // "excluded" outcome rather than a hash-mismatch on an unrelated subtree. + const targetNibbleAtDepth = nibbles[branchDepth] + const siblingNibble = (targetNibbleAtDepth + 1) % 16 + const forbidden = new Set([targetNibbleAtDepth, siblingNibble]) + const prefix = nibbles.slice(0, branchDepth) + + let divergentKey: Hex | undefined + for (let i = 0n; i < 500_000n; i++) { + const candidateKey = toHex(i, { size: 32 }) + const candidateNibbles = storageKeyNibbles(candidateKey) + let matchesPrefix = true + for (let j = 0; j < branchDepth; j++) { + if (candidateNibbles[j] !== prefix[j]) { + matchesPrefix = false + break + } + } + if (matchesPrefix && !forbidden.has(candidateNibbles[branchDepth])) { + divergentKey = candidateKey + break + } + } + expect(divergentKey).toBeDefined() + + const result = verifyStorageProof(root, { key: divergentKey as Hex, value: 0n, proof }) + expect(result).toEqual({ valid: true, kind: 'exclusion' }) + }) +}) + +describe('verifyStorageProof — embedded child node', () => { + const slot32 = toHex(2n, { size: 32 }) + const targetValue = 7n + const nibbles = storageKeyNibbles(slot32) + const branchDepth = 60 + const storageLeafValue = toRlp(toHex(minimalBytes(targetValue)), 'bytes') + const { root, proof } = buildEmbeddedLeafTrie(nibbles, branchDepth, storageLeafValue) + + it('has no separate proof entry for the embedded leaf', () => { + expect(proof).toHaveLength(2) // extension + branch only; leaf is inlined + }) + + it('verifies a valid inclusion proof through the embedded leaf', () => { + const result = verifyStorageProof(root, { key: slot32, value: targetValue, proof }) + expect(result).toEqual({ valid: true, kind: 'inclusion' }) + }) +}) + +describe('verifyStorageProof — empty trie', () => { + it('accepts a zero-value exclusion proof with an empty proof array against EMPTY_TRIE_ROOT', () => { + const result = verifyStorageProof(EMPTY_TRIE_ROOT, { key: toHex(1n, { size: 32 }), value: 0n, proof: [] }) + expect(result).toEqual({ valid: true, kind: 'exclusion' }) + }) + + it('rejects a non-zero claimed value against an empty trie', () => { + const result = verifyStorageProof(EMPTY_TRIE_ROOT, { key: toHex(1n, { size: 32 }), value: 5n, proof: [] }) + expect(result.valid).toBe(false) + }) + + it('rejects an empty proof array against a non-empty, non-canonical root', () => { + const result = verifyStorageProof(keccak256('0x1234'), { key: toHex(1n, { size: 32 }), value: 0n, proof: [] }) + expect(result.valid).toBe(false) + }) +}) + +describe('verifyEIP1186Proof and isValidStateProof', () => { + const nonce = 3n + const balance = 500n + const slot32 = toHex(5n, { size: 32 }) + const slotValue = 9n + + const accountNibbles = accountKeyNibbles(ADDRESS_A) + const storageNibbles = storageKeyNibbles(slot32) + const storageTrie = buildSingleLeafTrie(storageNibbles, toRlp(toHex(minimalBytes(slotValue)), 'bytes')) + + const accountRlp = encodeAccountRlp({ + nonce, + balance, + storageRoot: storageTrie.root, + codeHash: EMPTY_CODE_HASH, + }) + const accountTrie = buildSingleLeafTrie(accountNibbles, accountRlp) + + const fullProof: AccountProofInput = { + address: ADDRESS_A, + nonce, + balance, + codeHash: EMPTY_CODE_HASH, + storageHash: storageTrie.root, + accountProof: accountTrie.proof, + storageProof: [{ key: slot32, value: slotValue, proof: storageTrie.proof }], + } + + it('verifies account + storage together and reports both as valid', () => { + const result = verifyEIP1186Proof(accountTrie.root, fullProof) + expect(result.valid).toBe(true) + expect(result.account).toEqual({ valid: true, kind: 'inclusion' }) + expect(result.storageProofs).toEqual([{ key: slot32, result: { valid: true, kind: 'inclusion' } }]) + }) + + it('is false overall when the account proof is valid but a storage slot is wrong', () => { + const badProof: AccountProofInput = { + ...fullProof, + storageProof: [{ key: slot32, value: slotValue + 1n, proof: storageTrie.proof }], + } + const result = verifyEIP1186Proof(accountTrie.root, badProof) + expect(result.valid).toBe(false) + expect(result.account.valid).toBe(true) + expect(result.storageProofs[0].result.valid).toBe(false) + }) + + it('isValidStateProof returns true for a fully valid proof and false for a tampered one', () => { + expect(isValidStateProof(accountTrie.root, fullProof)).toBe(true) + expect(isValidStateProof(accountTrie.root, { ...fullProof, balance: balance + 1n })).toBe(false) + }) +}) diff --git a/tests/crypto/rlp.test.ts b/tests/crypto/rlp.test.ts new file mode 100644 index 00000000..6b5cb47a --- /dev/null +++ b/tests/crypto/rlp.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest' +import { toHex, toRlp } from 'viem' +import { decodeAccount, decodeHexPrefix, keyToNibbles, TrieEncodingError } from '../../src/crypto/rlp.js' + +describe('keyToNibbles', () => { + it('splits each byte into high/low nibbles', () => { + expect(keyToNibbles(new Uint8Array([0x1a, 0x2b]))).toEqual([1, 10, 2, 11]) + }) + + it('returns an empty array for an empty key', () => { + expect(keyToNibbles(new Uint8Array([]))).toEqual([]) + }) + + it('handles a full 32-byte key as 64 nibbles', () => { + const key = new Uint8Array(32).fill(0xff) + const nibbles = keyToNibbles(key) + expect(nibbles).toHaveLength(64) + expect(nibbles.every((n) => n === 15)).toBe(true) + }) +}) + +describe('decodeHexPrefix', () => { + it('decodes an even-length extension path (flag 0x0)', () => { + const result = decodeHexPrefix(new Uint8Array([0x00, 0x12, 0x34])) + expect(result).toEqual({ nibbles: [1, 2, 3, 4], isLeaf: false }) + }) + + it('decodes an odd-length extension path (flag 0x1)', () => { + // flag nibble = 1, first path nibble = 0xa, packed into byte 0x1a + const result = decodeHexPrefix(new Uint8Array([0x1a, 0x23])) + expect(result).toEqual({ nibbles: [0xa, 2, 3], isLeaf: false }) + }) + + it('decodes an even-length leaf path (flag 0x2)', () => { + const result = decodeHexPrefix(new Uint8Array([0x20, 0x12, 0x34])) + expect(result).toEqual({ nibbles: [1, 2, 3, 4], isLeaf: true }) + }) + + it('decodes an odd-length leaf path (flag 0x3)', () => { + const result = decodeHexPrefix(new Uint8Array([0x3a])) + expect(result).toEqual({ nibbles: [0xa], isLeaf: true }) + }) + + it('rejects an empty input', () => { + expect(() => decodeHexPrefix(new Uint8Array([]))).toThrow(TrieEncodingError) + }) + + it('rejects a flag nibble greater than 3', () => { + expect(() => decodeHexPrefix(new Uint8Array([0x40, 0x12]))).toThrow(/invalid hex-prefix flag/) + }) + + it('rejects a non-zero padding nibble on an even-length path', () => { + expect(() => decodeHexPrefix(new Uint8Array([0x05, 0x12]))).toThrow(/padding nibble/) + }) +}) + +describe('decodeAccount', () => { + const nonce = 7n + const balance = 123_456_789_000_000_000n + const storageRoot = `0x${'11'.repeat(32)}` as const + const codeHash = `0x${'22'.repeat(32)}` as const + + function encode(nonceVal: bigint, balanceVal: bigint): Uint8Array { + const minimal = (v: bigint) => { + if (v === 0n) return '0x' + let hex = v.toString(16) + if (hex.length % 2 === 1) hex = `0${hex}` + return `0x${hex}` as const + } + return toRlp([minimal(nonceVal), minimal(balanceVal), storageRoot, codeHash], 'bytes') + } + + it('decodes a well-formed 4-item account list', () => { + const decoded = decodeAccount(encode(nonce, balance)) + expect(decoded).toEqual({ nonce, balance, storageRoot, codeHash }) + }) + + it('decodes a zero nonce/balance (RLP empty-string encoding) as 0n', () => { + const decoded = decodeAccount(encode(0n, 0n)) + expect(decoded.nonce).toBe(0n) + expect(decoded.balance).toBe(0n) + }) + + it('rejects RLP that is not a list', () => { + const notAList = toRlp(toHex(new Uint8Array([1, 2, 3])), 'bytes') + expect(() => decodeAccount(notAList)).toThrow(TrieEncodingError) + }) + + it('rejects a list with the wrong number of items', () => { + const threeItems = toRlp(['0x01', '0x02', '0x03'], 'bytes') + expect(() => decodeAccount(threeItems)).toThrow(/4 items/) + }) + + it('rejects an account field that is itself a nested list', () => { + const nestedField = toRlp([['0x01'], '0x02', storageRoot, codeHash], 'bytes') + expect(() => decodeAccount(nestedField)).toThrow(/byte strings/) + }) +}) diff --git a/tests/crypto/trieFixtures.ts b/tests/crypto/trieFixtures.ts new file mode 100644 index 00000000..ee72f3ac --- /dev/null +++ b/tests/crypto/trieFixtures.ts @@ -0,0 +1,164 @@ +/** + * Hand-built Merkle Patricia Trie fixtures for StateProver tests. + * + * No live RPC/trie library is available in this repo, so these fixtures are + * constructed directly from trusted, independent primitives — viem's own + * `toRlp`/`keccak256` plus a hex-prefix encoder written fresh here (the + * inverse of, but independent from, `decodeHexPrefix` under test) — rather + * than generated by the StateProver code being tested. This keeps the + * fixtures an honest, non-circular check of the trie-walk implementation. + */ +import { keccak256, toBytes, toHex, toRlp, type Hex } from 'viem' + +export function encodeHexPrefixPath(nibbles: number[], isLeaf: boolean): Uint8Array { + const isOdd = nibbles.length % 2 === 1 + const flag = (isLeaf ? 0b10 : 0b00) | (isOdd ? 0b01 : 0b00) + const bytes: number[] = [] + let i = 0 + if (isOdd) { + bytes.push((flag << 4) | nibbles[0]) + i = 1 + } else { + bytes.push(flag << 4) + } + for (; i < nibbles.length; i += 2) { + bytes.push((nibbles[i] << 4) | nibbles[i + 1]) + } + return new Uint8Array(bytes) +} + +/** Minimal big-endian bytes for a non-negative bigint (RLP integer convention: 0 -> empty). */ +export function minimalBytes(value: bigint): Uint8Array { + if (value === 0n) return new Uint8Array(0) + let hex = value.toString(16) + if (hex.length % 2 === 1) hex = `0${hex}` + const bytes = new Uint8Array(hex.length / 2) + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16) + } + return bytes +} + +export function keyToNibblesLocal(key: Uint8Array): number[] { + const nibbles: number[] = [] + for (const byte of key) nibbles.push(byte >> 4, byte & 0x0f) + return nibbles +} + +/** Empty branch children: 16 empty slots + empty value slot. */ +function emptyBranchItems(): Uint8Array[] { + return Array.from({ length: 17 }, () => new Uint8Array(0)) +} + +export interface AccountFields { + nonce: bigint + balance: bigint + storageRoot: Hex + codeHash: Hex +} + +export function encodeAccountRlp(account: AccountFields): Uint8Array { + return toRlp( + [ + toHex(minimalBytes(account.nonce)), + toHex(minimalBytes(account.balance)), + account.storageRoot, + account.codeHash, + ], + 'bytes', + ) +} + +/** + * Builds a trivial single-leaf trie: the root node IS the leaf. Returns the + * root hash and a 1-entry proof array. Valid for both account and storage + * tries — pass the full 64-nibble key path and the raw (already RLP-encoded, + * for storage: doubly so) leaf value bytes. + */ +export function buildSingleLeafTrie(nibbles: number[], leafValue: Uint8Array): { root: Hex; proof: Hex[] } { + const path = encodeHexPrefixPath(nibbles, true) + const leafNode = toRlp([toHex(path), toHex(leafValue)], 'bytes') + return { root: keccak256(leafNode), proof: [toHex(leafNode)] } +} + +/** + * Builds a trie with one extension node (covering `nibbles.slice(0, branchDepth)`) + * leading to one branch node, whose child at `nibbles[branchDepth]` is a + * hash-referenced leaf holding `leafValue` for the remaining nibbles. A + * second, unrelated branch slot is filled with an arbitrary 32-byte + * placeholder hash so the branch node is well-formed — its content is never + * dereferenced by the returned proof, exactly as in a real trie where + * sibling subtrees are committed to by hash only. + */ +export function buildExtensionBranchTrie( + nibbles: number[], + branchDepth: number, + leafValue: Uint8Array, +): { root: Hex; proof: Hex[] } { + const targetNibble = nibbles[branchDepth] + const siblingNibble = (targetNibble + 1) % 16 + + const leafRemaining = nibbles.slice(branchDepth + 1) + const leafPath = encodeHexPrefixPath(leafRemaining, true) + const leafNode = toRlp([toHex(leafPath), toHex(leafValue)], 'bytes') + const leafHash = toBytes(keccak256(leafNode)) + + const siblingPlaceholder = keccak256(toBytes('sibling-placeholder'), 'bytes') + + const branchItems = emptyBranchItems() + branchItems[targetNibble] = leafHash + branchItems[siblingNibble] = siblingPlaceholder + const branchNode = toRlp(branchItems, 'bytes') + const branchHash = toBytes(keccak256(branchNode)) + + const extPath = encodeHexPrefixPath(nibbles.slice(0, branchDepth), false) + const extensionNode = toRlp([toHex(extPath), toHex(branchHash)], 'bytes') + + return { + root: keccak256(extensionNode), + proof: [toHex(extensionNode), toHex(branchNode), toHex(leafNode)], + } +} + +/** + * Builds a trie identical in shape to {@link buildExtensionBranchTrie}, but + * where the leaf under the target branch child is short enough (few + * remaining nibbles, small value) that its RLP encoding is under 32 bytes — + * forcing it to be embedded directly inside the branch node rather than + * referenced by hash. The returned proof therefore has only 2 entries + * (extension, branch); the embedded leaf is never a separate proof node. + */ +export function buildEmbeddedLeafTrie( + nibbles: number[], + branchDepth: number, + leafValue: Uint8Array, +): { root: Hex; proof: Hex[] } { + const targetNibble = nibbles[branchDepth] + const siblingNibble = (targetNibble + 1) % 16 + + const leafRemaining = nibbles.slice(branchDepth + 1) + const leafPath = encodeHexPrefixPath(leafRemaining, true) + const embeddedLeaf = [toHex(leafPath), toHex(leafValue)] + + // Sanity check for the fixture itself: confirm this really is embeddable. + const encodedSize = toBytes(toRlp(embeddedLeaf, 'bytes' as never)).length + if (encodedSize >= 32) { + throw new Error(`fixture error: leaf is ${encodedSize} bytes, not embeddable (must be < 32)`) + } + + const siblingPlaceholder = keccak256(toBytes('sibling-placeholder'), 'bytes') + + const branchItems: (Uint8Array | Hex[])[] = emptyBranchItems() + branchItems[targetNibble] = embeddedLeaf + branchItems[siblingNibble] = siblingPlaceholder + const branchNode = toRlp(branchItems as never, 'bytes') + const branchHash = toBytes(keccak256(branchNode)) + + const extPath = encodeHexPrefixPath(nibbles.slice(0, branchDepth), false) + const extensionNode = toRlp([toHex(extPath), toHex(branchHash)], 'bytes') + + return { + root: keccak256(extensionNode), + proof: [toHex(extensionNode), toHex(branchNode)], + } +}