UNPKG

@synthesizer-libs/statemanager

Version:
537 lines 27.2 kB
import { Common, Mainnet, VerkleAccessedStateType } from '@synthesizer-libs/common'; import { RLP } from '@ethereumjs/rlp'; import { Account, KECCAK256_NULL, MapDB, VERKLE_CODE_CHUNK_SIZE, VERKLE_CODE_OFFSET, VERKLE_NODE_WIDTH, VerkleLeafType, bigIntToBytes, bytesToBigInt, bytesToHex, chunkifyCode, createAddressFromString, createPartialAccount, createPartialAccountFromRLP, decodeVerkleLeafBasicData, encodeVerkleLeafBasicData, equalsBytes, generateChunkSuffixes, generateCodeStems, getVerkleStem, getVerkleTreeKeyForStorageSlot, hexToBytes, padToEven, setLengthLeft, setLengthRight, short, unpadBytes, unprefixedHexToBytes, } from '@synthesizer-libs/util'; import { LeafVerkleNodeValue, VerkleTree } from '@synthesizer-libs/verkle'; import debugDefault from 'debug'; import { keccak256 } from 'ethereum-cryptography/keccak.js'; import { OriginalStorageCache } from './cache/originalStorageCache.js'; import { modifyAccountFields } from './util.js'; const ZEROVALUE = '0x0000000000000000000000000000000000000000000000000000000000000000'; export class StatefulVerkleStateManager { constructor(opts) { // Post-state provided from the executionWitness. // Should not update. Used for comparing our computed post-state with the canonical one. this._postState = {}; this._preState = {}; /** * StateManager is run in DEBUG mode (default: false) * Taken from DEBUG environment variable * * Safeguards on debug() calls are added for * performance reasons to avoid string literal evaluation * @hidden */ this.DEBUG = false; /** * Gets the account associated with `address` or `undefined` if account does not exist * @param address - Address of the `account` to get */ this.getAccount = async (address) => { const elem = this._caches?.account?.get(address); if (elem !== undefined) { return elem.accountRLP !== undefined ? createPartialAccountFromRLP(elem.accountRLP) : undefined; } const stem = getVerkleStem(this.verkleCrypto, address, 0); // First retrieve the account "header" values from the trie const accountValues = await this._trie.get(stem, [ VerkleLeafType.BasicData, VerkleLeafType.CodeHash, ]); let account; if (accountValues[0] !== undefined) { const basicData = decodeVerkleLeafBasicData(accountValues[0]); account = createPartialAccount({ version: basicData.version, balance: basicData.balance, nonce: basicData.nonce, // Codehash is either untouched (i.e. undefined) or deleted (i.e. overwritten with zeros) codeHash: accountValues[1] === undefined || equalsBytes(accountValues[1], new Uint8Array(32)) ? KECCAK256_NULL : accountValues[1], codeSize: basicData.codeSize, storageRoot: KECCAK256_NULL, // TODO: Add storage stuff }); } else if (accountValues[1] === undefined) { // account does not exist if both basic fields and codehash are undefined if (this.DEBUG) { this._debug(`getAccount address=${address.toString()} from DB (non-existent)`); } this._caches?.account?.put(address, account); } if (this.DEBUG) { this._debug(`getAccount address=${address.toString()} stem=${short(stem)}`); } return account; }; /** * Saves an account into state under the provided `address`. * @param address - Address under which to store `account` * @param account - The account to store or undefined if to be deleted */ this.putAccount = async (address, account) => { if (this.DEBUG) { this._debug(`putAccount address=${address} nonce=${account?.nonce} balance=${account?.balance} contract=${account && account.isContract() ? 'yes' : 'no'} empty=${account && account.isEmpty() ? 'yes' : 'no'}`); } if (this._caches?.account === undefined) { if (account !== undefined) { const stem = getVerkleStem(this.verkleCrypto, address, 0); const basicDataBytes = encodeVerkleLeafBasicData(account); await this._trie.put(stem, [VerkleLeafType.BasicData, VerkleLeafType.CodeHash], [basicDataBytes, account.codeHash]); } else { // Delete account await this.deleteAccount(address); } } else { if (account !== undefined) { this._caches?.account?.put(address, account, true); } else { this._caches?.account?.del(address); } } }; /** * Deletes an account from state under the provided `address`. * @param address - Address of the account which should be deleted */ this.deleteAccount = async (address) => { if (this.DEBUG) { this._debug(`Delete account ${address}`); } this._caches?.deleteAccount(address); if (this._caches?.account === undefined) { const stem = getVerkleStem(this.verkleCrypto, address); // TODO: Determine the best way to clear code/storage for an account when deleting // Will need to inspect all possible code and storage keys to see if it's anything // other than untouched leaf values // Special instance where we delete the account and revert the trie value to untouched await this._trie.put(stem, [VerkleLeafType.BasicData, VerkleLeafType.CodeHash], [LeafVerkleNodeValue.Untouched, LeafVerkleNodeValue.Untouched]); } }; this.modifyAccountFields = async (address, accountFields) => { //@ts-ignore await modifyAccountFields(this, address, accountFields); }; this.putCode = async (address, value) => { if (this.DEBUG) { this._debug(`putCode address=${address.toString()} value=${short(value)}`); } this._caches?.code?.put(address, value); const codeHash = keccak256(value); if (equalsBytes(codeHash, KECCAK256_NULL)) { // If the code hash is the null hash, no code has to be stored return; } if ((await this.getAccount(address)) === undefined) { await this.putAccount(address, new Account()); } if (this.DEBUG) { this._debug(`Update codeHash (-> ${short(codeHash)}) for account ${address}`); } const codeChunks = chunkifyCode(value); const chunkStems = await generateCodeStems(codeChunks.length, address, this.verkleCrypto); const chunkSuffixes = generateChunkSuffixes(codeChunks.length); // Put the code chunks corresponding to the first stem (up to 128 chunks) await this._trie.put(chunkStems[0], chunkSuffixes.slice(0, codeChunks.length <= VERKLE_CODE_OFFSET ? codeChunks.length : VERKLE_CODE_OFFSET), codeChunks.slice(0, codeChunks.length <= VERKLE_CODE_OFFSET ? codeChunks.length : VERKLE_CODE_OFFSET)); // Put additional chunks under additional stems as applicable for (let stem = 1; stem < chunkStems.length; stem++) { const sliceStart = VERKLE_CODE_OFFSET + VERKLE_NODE_WIDTH * (stem - 1); const sliceEnd = value.length <= VERKLE_CODE_OFFSET + VERKLE_NODE_WIDTH * stem ? value.length : VERKLE_CODE_OFFSET + VERKLE_NODE_WIDTH * stem; await this._trie.put(chunkStems[stem], chunkSuffixes.slice(sliceStart, sliceEnd), codeChunks.slice(sliceStart, sliceEnd)); } await this.modifyAccountFields(address, { codeHash, codeSize: value.length }); }; this.getCode = async (address) => { if (this.DEBUG) { this._debug(`getCode address=${address.toString()}`); } const elem = this._caches?.code?.get(address); if (elem !== undefined) { return elem.code ?? new Uint8Array(0); } const account = await this.getAccount(address); if (!account) { return new Uint8Array(0); } if (!account.isContract()) { return new Uint8Array(0); } // allocate the code const codeSize = account.codeSize; const stems = await generateCodeStems(Math.ceil(codeSize / VERKLE_CODE_CHUNK_SIZE), address, this.verkleCrypto); const chunkSuffixes = generateChunkSuffixes(Math.ceil(codeSize / VERKLE_CODE_CHUNK_SIZE)); const chunksByStem = new Array(stems.length); // Retrieve the code chunks stored in the first leaf node chunksByStem[0] = await this._trie.get(stems[0], chunkSuffixes.slice(0, codeSize <= VERKLE_CODE_OFFSET ? codeSize : VERKLE_CODE_OFFSET)); // Retrieve code chunks on any additional stems for (let stem = 1; stem < stems.length; stem++) { const sliceStart = VERKLE_CODE_OFFSET + VERKLE_NODE_WIDTH * (stem - 1); const sliceEnd = codeSize <= VERKLE_CODE_OFFSET + VERKLE_NODE_WIDTH * stem ? codeSize : VERKLE_CODE_OFFSET + VERKLE_NODE_WIDTH * stem; chunksByStem[stem] = await this._trie.get(stems[stem], chunkSuffixes.slice(sliceStart, sliceEnd)); } const chunks = chunksByStem.flat(); const code = new Uint8Array(codeSize); // Insert code chunks into final array (skipping PUSHDATA overflow indicator byte) for (let x = 0; x < chunks.length; x++) { if (chunks[x] === undefined) throw new Error(`expected code chunk at ID ${x}, got undefined`); // Determine code ending byte (if we're on the last chunk) let sliceEnd = 32; if (x === chunks.length - 1) { sliceEnd = (codeSize % VERKLE_CODE_CHUNK_SIZE) + 1; } code.set(chunks[x].slice(1, sliceEnd), code.byteOffset + x * VERKLE_CODE_CHUNK_SIZE); } this._caches?.code?.put(address, code); return code; }; this.getCodeSize = async (address) => { const accountBytes = (await this._trie.get(getVerkleStem(this.verkleCrypto, address), [VerkleLeafType.BasicData]))[0]; if (accountBytes === undefined) return 0; return decodeVerkleLeafBasicData(accountBytes).codeSize; }; this.getStorage = async (address, key) => { if (key.length !== 32) { throw new Error('Storage key must be 32 bytes long'); } const cachedValue = this._caches?.storage?.get(address, key); if (cachedValue !== undefined) { const decoded = RLP.decode(cachedValue ?? new Uint8Array(0)); return decoded; } const account = await this.getAccount(address); if (!account) { return new Uint8Array(); } const storageKey = await getVerkleTreeKeyForStorageSlot(address, bytesToBigInt(key, true), this.verkleCrypto); const value = await this._trie.get(storageKey.slice(0, 31), [storageKey[31]]); this._caches?.storage?.put(address, key, value[0] ?? hexToBytes('0x80')); const decoded = RLP.decode(value[0] ?? new Uint8Array(0)); return decoded; }; this.putStorage = async (address, key, value) => { value = unpadBytes(value); this._caches?.storage?.put(address, key, RLP.encode(value)); if (this._caches?.storage === undefined) { const storageKey = await getVerkleTreeKeyForStorageSlot(address, bytesToBigInt(key, true), this.verkleCrypto); await this._trie.put(storageKey.slice(0, 31), [storageKey[31]], [RLP.encode(value)]); } }; this.clearStorage = async (address) => { // TODO: Determine if it's possible to clear the actual slots in the trie // since the EIP doesn't seem to state how to handle this // The main concern I have is that we have no way of identifying all storage slots // for a given account so we can't correctly update the trie's root hash // (since presumably "clearStorage" would imply writing over all of the storage slots with zeros) // Also, do we still need a storageRoot? - presumably not since we don't have separate storage tries this._caches?.storage?.clearStorage(address); }; this.checkpoint = async () => { this._trie.checkpoint(); this._caches?.checkpoint(); this._checkpointCount++; }; this.commit = async () => { await this._trie.commit(); this._caches?.commit(); this._checkpointCount--; if (this._checkpointCount === 0) { await this.flush(); this.originalStorageCache.clear(); } if (this.DEBUG) { this._debug(`state checkpoint committed`); } }; this.revert = async () => { await this._trie.revert(); this._caches?.revert(); this._checkpointCount--; if (this._checkpointCount === 0) { await this.flush(); this.originalStorageCache.clear(); } }; this.flush = async () => { const codeItems = this._caches?.code?.flush() ?? []; for (const item of codeItems) { const addr = createAddressFromString(`0x${item[0]}`); const code = item[1].code; if (code === undefined) { continue; } await this.putCode(addr, code); } const storageItems = this._caches?.storage?.flush() ?? []; for (const item of storageItems) { const address = createAddressFromString(`0x${item[0]}`); const keyHex = item[1]; const keyBytes = unprefixedHexToBytes(keyHex); const value = item[2]; const decoded = RLP.decode(value ?? new Uint8Array(0)); const account = await this.getAccount(address); if (account) { await this.putStorage(address, keyBytes, decoded); } } const accountItems = this._caches?.account?.flush() ?? []; for (const item of accountItems) { const address = createAddressFromString(`0x${item[0]}`); const elem = item[1]; if (elem.accountRLP === undefined) { await this.deleteAccount(address); } else { const account = createPartialAccountFromRLP(elem.accountRLP); await this.putAccount(address, account); } } }; // Skip DEBUG calls unless 'ethjs' included in environmental DEBUG variables // Additional window check is to prevent vite browser bundling (and potentially other) to break this.DEBUG = typeof window === 'undefined' ? (process?.env?.DEBUG?.includes('ethjs') ?? false) : false; this._checkpointCount = 0; if (opts.common?.isActivatedEIP(6800) === false) throw new Error('EIP-6800 required for verkle state management'); this.common = opts.common ?? new Common({ chain: Mainnet, eips: [6800] }); this._trie = opts.trie ?? new VerkleTree({ verkleCrypto: opts.verkleCrypto, db: new MapDB(), useRootPersistence: false, cacheSize: 0, }); this._debug = debugDefault('statemanager:verkle:stateful'); this.originalStorageCache = new OriginalStorageCache(this.getStorage.bind(this)); this._caches = opts.caches; this.keccakFunction = opts.common?.customCrypto.keccak256 ?? keccak256; this.verkleCrypto = opts.verkleCrypto; } initVerkleExecutionWitness(_blockNum, executionWitness) { if (executionWitness === null || executionWitness === undefined) { const errorMsg = `Invalid executionWitness=${executionWitness} for initVerkleExecutionWitness`; this._debug(errorMsg); throw Error(errorMsg); } // Populate the pre-state and post-state from the executionWitness const preStateRaw = executionWitness.stateDiff.flatMap(({ stem, suffixDiffs }) => { const suffixDiffPairs = suffixDiffs.map(({ currentValue, suffix }) => { const key = `${stem}${padToEven(Number(suffix).toString(16))}`; return { [key]: currentValue, }; }); return suffixDiffPairs; }); // also maintain a separate preState unaffected by any changes in _state this._preState = preStateRaw.reduce((prevValue, currentValue) => { const acc = { ...prevValue, ...currentValue }; return acc; }, {}); const postStateRaw = executionWitness.stateDiff.flatMap(({ stem, suffixDiffs }) => { const suffixDiffPairs = suffixDiffs.map(({ newValue, currentValue, suffix }) => { const key = `${stem}${padToEven(Number(suffix).toString(16))}`; // A postState value of null means there was no change from the preState. // In this implementation, we therefore replace null with the preState. const value = newValue ?? currentValue; return { [key]: value, }; }); return suffixDiffPairs; }); const postState = postStateRaw.reduce((prevValue, currentValue) => { const acc = { ...prevValue, ...currentValue }; return acc; }, {}); this._postState = postState; this._debug('initVerkleExecutionWitness postState', this._postState); } async getComputedValue(accessedState) { const { address, type } = accessedState; switch (type) { case VerkleAccessedStateType.BasicData: { if (this._caches === undefined) { const accountData = await this.getAccount(address); if (accountData === undefined) { return null; } const basicDataBytes = encodeVerkleLeafBasicData(accountData); return bytesToHex(basicDataBytes); } else { const encodedAccount = this._caches?.account?.get(address)?.accountRLP; this._debug(`we have encoded account ${encodedAccount}`); if (encodedAccount === undefined) { return null; } const basicDataBytes = encodeVerkleLeafBasicData(createPartialAccountFromRLP(encodedAccount)); return bytesToHex(basicDataBytes); } } case VerkleAccessedStateType.CodeHash: { if (this._caches === undefined) { const accountData = await this.getAccount(address); if (accountData === undefined) { return null; } return bytesToHex(accountData.codeHash); } else { const encodedAccount = this._caches?.account?.get(address)?.accountRLP; if (encodedAccount === undefined) { return null; } return bytesToHex(createPartialAccountFromRLP(encodedAccount).codeHash); } } case VerkleAccessedStateType.Code: { const { codeOffset } = accessedState; let code = null; if (this._caches === undefined) { code = await this.getCode(address); if (code === undefined) { return null; } } else { code = this._caches?.code?.get(address)?.code; if (code === undefined) { return null; } } // we can only compare the actual code because to compare the first byte would // be very tricky and impossible in certain scenarios like when the previous code chunk // was not accessed and hence not even provided in the witness return bytesToHex(setLengthRight(code.slice(codeOffset, codeOffset + VERKLE_CODE_CHUNK_SIZE), VERKLE_CODE_CHUNK_SIZE)); } case VerkleAccessedStateType.Storage: { const { slot } = accessedState; const key = setLengthLeft(bigIntToBytes(slot), 32); let storage = null; if (this._caches === undefined) { storage = await this.getStorage(address, key); if (storage === undefined) { return null; } } else { storage = this._caches?.storage?.get(address, key); } if (storage === undefined) { return null; } return bytesToHex(setLengthLeft(storage, 32)); } } } // Verifies that the witness post-state matches the computed post-state async verifyPostState(accessWitness) { // track what all chunks were accessed so as to compare in the end if any chunks were missed // in access while comparing against the provided poststate in the execution witness const accessedChunks = new Map(); // switch to false if postVerify fails let postFailures = 0; for (const accessedState of accessWitness?.accesses() ?? []) { const { address, type } = accessedState; let extraMeta = ''; if (accessedState.type === VerkleAccessedStateType.Code) { extraMeta = `codeOffset=${accessedState.codeOffset}`; } else if (accessedState.type === VerkleAccessedStateType.Storage) { extraMeta = `slot=${accessedState.slot}`; } const { chunkKey } = accessedState; accessedChunks.set(chunkKey, true); const computedValue = await this.getComputedValue(accessedState); if (computedValue === undefined) { this.DEBUG && this._debug(`Block accesses missing in canonical address=${address} type=${type} ${extraMeta} chunkKey=${chunkKey}`); postFailures++; continue; } let canonicalValue = this._postState[chunkKey]; if (canonicalValue === undefined) { this.DEBUG && this._debug(`Block accesses missing in canonical address=${address} type=${type} ${extraMeta} chunkKey=${chunkKey}`); postFailures++; continue; } // if the access type is code, then we can't match the first byte because since the computed value // doesn't has the first byte for push data since previous chunk code itself might not be available if (accessedState.type === VerkleAccessedStateType.Code) { // computedValue = computedValue !== null ? `0x${computedValue.slice(4)}` : null canonicalValue = canonicalValue !== null ? `0x${canonicalValue.slice(4)}` : null; } else if (accessedState.type === VerkleAccessedStateType.Storage && canonicalValue === null && computedValue === ZEROVALUE) { canonicalValue = ZEROVALUE; } if (computedValue !== canonicalValue) { if (type === VerkleAccessedStateType.BasicData) { this.DEBUG && this._debug(`canonical value: `, canonicalValue === null ? null : decodeVerkleLeafBasicData(hexToBytes(canonicalValue))); this.DEBUG && this._debug(`computed value: `, computedValue === null ? null : decodeVerkleLeafBasicData(hexToBytes(computedValue))); } this.DEBUG && this._debug(`Block accesses mismatch address=${address} type=${type} ${extraMeta} chunkKey=${chunkKey}`); this.DEBUG && this._debug(`expected=${canonicalValue}`); this.DEBUG && this._debug(`computed=${computedValue}`); postFailures++; } } for (const canChunkKey of Object.keys(this._postState)) { if (accessedChunks.get(canChunkKey) === undefined) { this.DEBUG && this._debug(`Missing chunk access for canChunkKey=${canChunkKey}`); postFailures++; } } const verifyPassed = postFailures === 0; this.DEBUG && this._debug(`verifyPostState verifyPassed=${verifyPassed} postFailures=${postFailures}`); return verifyPassed; } getStateRoot() { return Promise.resolve(this._trie.root()); } setStateRoot(stateRoot, clearCache) { this._trie.root(stateRoot); clearCache === true && this.clearCaches(); return Promise.resolve(); } hasStateRoot(_root) { throw new Error('Method not implemented.'); } dumpStorage(_address) { throw new Error('Method not implemented.'); } dumpStorageRange(_address, _startKey, _limit) { throw new Error('Method not implemented.'); } clearCaches() { this._caches?.clear(); } shallowCopy(_downlevelCaches) { throw new Error('Method not implemented.'); } async checkChunkWitnessPresent(_address, _codeOffset) { throw new Error('Method not implemented.'); } } //# sourceMappingURL=statefulVerkleStateManager.js.map