UNPKG

@synthesizer-libs/statemanager

Version:
525 lines 24.6 kB
import { VerkleAccessedStateType } from '@synthesizer-libs/common'; import { Account, KECCAK256_NULL, KECCAK256_NULL_S, VERKLE_CODE_CHUNK_SIZE, VerkleLeafType, bigIntToBytes, bytesToHex, createPartialAccount, createPartialAccountFromRLP, decodeVerkleLeafBasicData, encodeVerkleLeafBasicData, getVerkleKey, getVerkleStem, getVerkleTreeKeyForCodeChunk, getVerkleTreeKeyForStorageSlot, hexToBytes, padToEven, setLengthLeft, setLengthRight, short, toBytes, } from '@synthesizer-libs/util'; import debugDefault from 'debug'; import { keccak256 } from 'ethereum-cryptography/keccak.js'; import { OriginalStorageCache } from './cache/index.js'; import { modifyAccountFields } from './util.js'; const PUSH_OFFSET = 95; // eslint-disable-next-line @typescript-eslint/no-unused-vars const PUSH1 = PUSH_OFFSET + 1; // eslint-disable-next-line @typescript-eslint/no-unused-vars const PUSH32 = PUSH_OFFSET + 32; const ZEROVALUE = '0x0000000000000000000000000000000000000000000000000000000000000000'; /** * Stateless Verkle StateManager implementation for the VM. * * Experimental. * * This State Manager enables stateless block execution by building a * temporary (1-block) state from the verkle block witness. * The Stateless Verkle State Manager then uses that populated state * to fetch data requested by the the VM. * */ export class StatelessVerkleStateManager { /** * Instantiate the StateManager interface. */ constructor(opts) { /** * StateManager is run in DEBUG mode (default: false) * Taken from DEBUG environment variable * * Safeguards on this._debug() calls are added for * performance reasons to avoid string literal evaluation * @hidden */ this.DEBUG = false; this._blockNum = BigInt(0); // State along execution (should update) this._state = {}; // Post-state provided from the executionWitness. // Should not update. Used for comparing our computed post-state with the canonical one. this._postState = {}; this._preState = {}; // Checkpointing this._checkpoints = []; this.originalStorageCache = new OriginalStorageCache(this.getStorage.bind(this)); this._caches = opts.caches; this.keccakFunction = opts.common?.customCrypto.keccak256 ?? keccak256; this._debug = debugDefault('statemanager:verkle:stateless'); if (opts.verkleCrypto === undefined) { throw new Error('verkle crypto required'); } this.verkleCrypto = opts.verkleCrypto; // 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; } async getTransitionStateRoot(_, __) { throw Error('not implemented'); } initVerkleExecutionWitness(blockNum, executionWitness) { this._blockNum = blockNum; if (executionWitness === null || executionWitness === undefined) { const errorMsg = `Invalid executionWitness=${executionWitness} for initVerkleExecutionWitness`; this._debug(errorMsg); throw Error(errorMsg); } this._executionWitness = executionWitness; this._proof = executionWitness.verkleProof; // 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; }); const preState = preStateRaw.reduce((prevValue, currentValue) => { const acc = { ...prevValue, ...currentValue }; return acc; }, {}); this._state = preState; // 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, 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 ?? this._state[key]; return { [key]: value, }; }); return suffixDiffPairs; }); const postState = postStateRaw.reduce((prevValue, currentValue) => { const acc = { ...prevValue, ...currentValue }; return acc; }, {}); this._postState = postState; this._debug('initVerkleExecutionWitness preState', this._state); this._debug('initVerkleExecutionWitness postState', this._postState); } async checkChunkWitnessPresent(address, codeOffset) { const chunkId = codeOffset / VERKLE_CODE_CHUNK_SIZE; const chunkKey = bytesToHex(await getVerkleTreeKeyForCodeChunk(address, chunkId, this.verkleCrypto)); return this._state[chunkKey] !== undefined; } /** * Copies the current instance of the `StateManager` * at the last fully committed point, i.e. as if all current * checkpoints were reverted. */ shallowCopy(downlevelCaches = true) { const stateManager = new StatelessVerkleStateManager({ caches: this._caches?.shallowCopy(downlevelCaches), verkleCrypto: this.verkleCrypto, }); return stateManager; } /** * Adds `value` to the state trie as code, and sets `codeHash` on the account * corresponding to `address` to reference this. * @param address - Address of the `account` to add the `code` for * @param value - The value of the `code` */ async putCode(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 (KECCAK256_NULL === codeHash) { // 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()); } await this.modifyAccountFields(address, { codeHash }); } /** * Gets the code corresponding to the provided `address`. * @param address - Address to get the `code` for * @returns {Promise<Uint8Array>} - Resolves with the code corresponding to the provided address. * Returns an empty `Uint8Array` if the account has no associated code. */ async getCode(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 and copy onto it from the available witness chunks const codeSize = account.codeSize; // allocate enough to fit the last chunk const accessedCode = new Uint8Array(codeSize + VERKLE_CODE_CHUNK_SIZE); const chunks = Math.floor(codeSize / VERKLE_CODE_CHUNK_SIZE) + 1; for (let chunkId = 0; chunkId < chunks; chunkId++) { const chunkKey = bytesToHex(await getVerkleTreeKeyForCodeChunk(address, chunkId, this.verkleCrypto)); const codeChunk = this._state[chunkKey]; if (codeChunk === null) { const errorMsg = `Invalid access to a non existent code chunk with chunkKey=${chunkKey}`; this.DEBUG && this._debug(errorMsg); throw Error(errorMsg); } const codeOffset = chunkId * VERKLE_CODE_CHUNK_SIZE; // if code chunk was accessed as per the provided witnesses copy it over if (codeChunk !== undefined) { // actual code starts from index 1 in chunk, 0th index is if there are any push data bytes const actualChunk = hexToBytes(codeChunk).slice(1); accessedCode.set(actualChunk, codeOffset); } else { // else fill this unaccessed segment with invalid opcode since the evm execution shouldn't // end up here accessedCode.fill(0xfe, codeOffset, VERKLE_CODE_CHUNK_SIZE); } } // Return accessedCode where only accessed code has been copied const contactCode = accessedCode.slice(0, codeSize); this._caches?.code?.put(address, contactCode); return contactCode; } async getCodeSize(address) { const elem = this._caches?.account?.get(address); if (elem !== undefined) { const account = elem.accountRLP !== undefined ? createPartialAccountFromRLP(elem.accountRLP) : undefined; if (account === undefined) { const errorMsg = `account=${account} in cache`; this.DEBUG && this._debug(errorMsg); throw Error(errorMsg); } return account.codeSize; } // load the account basic fields and codeSize should be in it const account = await this.getAccount(address); if (account === undefined) { return 0; } return account.codeSize; } /** * Gets the storage value associated with the provided `address` and `key`. This method returns * the shortest representation of the stored value. * @param address - Address of the account to get the storage for * @param key - Key in the account's storage to get the value for. Must be 32 bytes long. * @returns {Promise<Uint8Array>} - The storage value for the account * corresponding to the provided address at the provided key. * If this does not exist an empty `Uint8Array` is returned. */ async getStorage(address, key) { const value = this._caches?.storage?.get(address, key); if (value !== undefined) { return value; } const storageKey = await getVerkleTreeKeyForStorageSlot(address, BigInt(bytesToHex(key)), this.verkleCrypto); const storageValue = toBytes(this._state[bytesToHex(storageKey)]); this._caches?.storage?.put(address, key, storageValue ?? hexToBytes('0x80')); return storageValue; } /** * Adds value to the state for the `account` * corresponding to `address` at the provided `key`. * @param address - Address to set a storage value for * @param key - Key to set the value at. Must be 32 bytes long. * @param value - Value to set at `key` for account corresponding to `address`. Cannot be more than 32 bytes. Leading zeros are stripped. If it is a empty or filled with zeros, deletes the value. */ async putStorage(address, key, value) { if (this._caches?.storage !== undefined) { this._caches.storage.put(address, key, value); } else { // TODO: Consider refactoring this in a writeContractStorage function? Like in stateManager.ts const storageKey = await getVerkleTreeKeyForStorageSlot(address, BigInt(bytesToHex(key)), this.verkleCrypto); this._state[bytesToHex(storageKey)] = bytesToHex(setLengthRight(value, 32)); } } // Note from Gabriel: Clearing storage is not actually not possible in Verkle. // This is because the storage keys are scattered throughout the verkle tree. /** * Clears all storage entries for the account corresponding to `address`. * @param address - Address to clear the storage of */ async clearStorage(address) { const stem = getVerkleStem(this.verkleCrypto, address, 0); const codeHashKey = getVerkleKey(stem, VerkleLeafType.CodeHash); this._caches?.storage?.clearStorage(address); // Update codeHash to `c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470` this._state[bytesToHex(codeHashKey)] = KECCAK256_NULL_S; } async getAccount(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); const basicDataKey = getVerkleKey(stem, VerkleLeafType.BasicData); const codeHashKey = getVerkleKey(stem, VerkleLeafType.CodeHash); const basicDataRaw = this._state[bytesToHex(basicDataKey)]; const codeHashRaw = this._state[bytesToHex(codeHashKey)]; // check if the account didn't exist if any of the basic keys have null if (basicDataRaw === null || codeHashRaw === null) { // check any of the other key shouldn't have string input available as this account didn't exist if (typeof basicDataRaw === `string` || typeof codeHashRaw === 'string') { const errorMsg = `Invalid witness for a non existing address=${address} stem=${bytesToHex(stem)}`; this.DEBUG && this._debug(errorMsg); throw Error(errorMsg); } else { return undefined; } } // check if codehash is correct 32 bytes prefixed hex string if (codeHashRaw !== undefined && codeHashRaw !== null && codeHashRaw.length !== 66) { const errorMsg = `Invalid codeHashRaw=${codeHashRaw} for address=${address} chunkKey=${bytesToHex(codeHashKey)}`; this.DEBUG && this._debug(errorMsg); throw Error(errorMsg); } if (basicDataRaw === undefined && codeHashRaw === undefined) { const errorMsg = `No witness bundled for address=${address} stem=${bytesToHex(stem)}`; this.DEBUG && this._debug(errorMsg); throw Error(errorMsg); } const { version, balance, nonce, codeSize } = decodeVerkleLeafBasicData(hexToBytes(basicDataRaw)); const account = createPartialAccount({ version, balance, nonce, codeHash: typeof codeHashRaw === 'string' ? hexToBytes(codeHashRaw) : null, // if codeSizeRaw is null, it means account didn't exist or it was EOA either way codeSize is 0 // if codeSizeRaw is undefined, then we pass in null which in our context of partial account means // not specified codeSize, storageRoot: null, }); if (this.DEBUG) { this._debug(`getAccount address=${address.toString()} stem=${short(stem)}`); } this._caches?.account?.put(address, account, true); return account; } async putAccount(address, account) { if (this.DEBUG) { this._debug(`putAccount address=${address.toString()}`); } if (this._caches?.account === undefined) { const stem = getVerkleStem(this.verkleCrypto, address, 0); const basicDataKey = getVerkleKey(stem, VerkleLeafType.BasicData); const basicDataBytes = encodeVerkleLeafBasicData(account); this._state[bytesToHex(basicDataKey)] = bytesToHex(basicDataBytes); } 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 */ async deleteAccount(address) { if (this.DEBUG) { this._debug(`Delete account ${address}`); } this._caches?.deleteAccount(address); } async modifyAccountFields(address, accountFields) { await modifyAccountFields(this, address, accountFields); } // Verifies that the witness post-state matches the computed post-state 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 = this.getComputedValue(accessedState) ?? this._preState[chunkKey]; 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) { 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}`); // This is async so the stateful variant can use the same interface method return Promise.resolve(verifyPassed); } getComputedValue(accessedState) { const { address, type } = accessedState; switch (type) { case VerkleAccessedStateType.BasicData: { const encodedAccount = this._caches?.account?.get(address)?.accountRLP; if (encodedAccount === undefined) { return null; } const basicDataBytes = encodeVerkleLeafBasicData(createPartialAccountFromRLP(encodedAccount)); return bytesToHex(basicDataBytes); } case VerkleAccessedStateType.CodeHash: { const encodedAccount = this._caches?.account?.get(address)?.accountRLP; if (encodedAccount === undefined) { return null; } return bytesToHex(createPartialAccountFromRLP(encodedAccount).codeHash); } case VerkleAccessedStateType.Code: { const { codeOffset } = accessedState; const 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); const storage = this._caches?.storage?.get(address, key); if (storage === undefined) { return null; } return bytesToHex(setLengthLeft(storage, 32)); } } } /** * Checkpoints the current state of the StateManager instance. * State changes that follow can then be committed by calling * `commit` or `reverted` by calling rollback. */ async checkpoint() { this._checkpoints.push(this._state); this._caches?.checkpoint(); } /** * Commits the current change-set to the instance since the * last call to checkpoint. */ async commit() { this._checkpoints.pop(); this._caches?.commit(); } // TODO async hasStateRoot(_) { return true; } /** * Reverts the current change-set to the instance since the * last call to checkpoint. */ async revert() { // setup trie checkpointing this._checkpoints.pop(); this._caches?.revert(); } /** * Writes all cache items to the trie */ async flush() { } /** * Gets the cache state root. * This is used to persist the stateRoot between blocks, so that blocks can retrieve the stateRoot of the parent block. * This is required to verify and prove verkle execution witnesses. * @returns {Promise<Uint8Array>} - Returns the cached state root */ async getStateRoot() { if (this._cachedStateRoot === undefined) { throw new Error('Cache state root missing'); } return this._cachedStateRoot; } /** * Sets the cache state root. * This is used to persist the stateRoot between blocks, so that blocks can retrieve the stateRoot of the parent block. * @param stateRoot - The stateRoot to set */ async setStateRoot(stateRoot) { this._cachedStateRoot = stateRoot; } /** * Clears all underlying caches */ clearCaches() { this._caches?.clear(); } // TODO: Removing this causes a Kaustinen6 test in client to fail // Seems to point to a more general (non-severe) bug and can likely be fixed // by having the `statelessVerkle` config option more properly set by the // test for the check in the VM execution to call into this method generateCanonicalGenesis(_initState) { return Promise.resolve(); } } //# sourceMappingURL=statelessVerkleStateManager.js.map