UNPKG

@polygon-hermez/vm

Version:
506 lines (452 loc) 17.2 kB
const Set = require('core-js-pure/es/set') import Common, { Chain, Hardfork } from '@ethereumjs/common' import { AccessList, AccessListItem } from '@ethereumjs/tx' import { debug as createDebugLogger, Debugger } from 'debug' import { Account, Address, toBuffer } from 'ethereumjs-util' import { getActivePrecompiles, ripemdPrecompileAddress } from '../evm/precompiles' import Cache from './cache' import { DefaultStateManagerOpts } from './stateManager' type AddressHex = string /** * Abstract BaseStateManager class for the non-storage-backend * related functionality parts of a StateManager like keeping * track of accessed storage (`EIP-2929`) or touched accounts * (`EIP-158`). * * This is not a full StateManager implementation in itself but * can be used to ease implementing an own StateManager. * * Note that the implementation is pretty new (October 2021) * and we cannot guarantee a stable interface yet. */ export abstract class BaseStateManager { _common: Common _debug: Debugger _cache!: Cache _touched: Set<AddressHex> _touchedStack: Set<AddressHex>[] _customTouched: Set<AddressHex> _originalStorageCache: Map<AddressHex, Map<AddressHex, Buffer>> // EIP-2929 address/storage trackers. // This maps both the accessed accounts and the accessed storage slots. // It is a Map(Address => StorageSlots) // It is possible that the storage slots set is empty. This means that the address is warm. // It is not possible to have an accessed storage slot on a cold address (which is why this structure works) // Each call level tracks their access themselves. // In case of a commit, copy everything if the value does not exist, to the level above // In case of a revert, discard any warm slots. _accessedStorage: Map<string, Set<string>>[] // Backup structure for address/storage tracker frames on reverts // to also include on access list generation _accessedStorageReverted: Map<string, Set<string>>[] _checkpointCount: number /** * 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 */ protected readonly DEBUG: boolean = false /** * Needs to be called from the subclass constructor */ constructor(opts: DefaultStateManagerOpts) { let common = opts.common if (!common) { common = new Common({ chain: Chain.Mainnet, hardfork: Hardfork.Petersburg }) } this._common = common this._touched = new Set() this._customTouched = new Set() this._touchedStack = [] this._originalStorageCache = new Map() this._accessedStorage = [new Map()] this._accessedStorageReverted = [new Map()] this._checkpointCount = 0 // Safeguard if "process" is not available (browser) if (process !== undefined && process.env.DEBUG) { this.DEBUG = true } this._debug = createDebugLogger('vm:state') } /** * Gets the account associated with `address`. Returns an empty account if the account does not exist. * @param address - Address of the `account` to get */ async getAccount(address: Address): Promise<Account> { const account = await this._cache.getOrLoad(address) 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 */ async putAccount(address: Address, account: Account): Promise<void> { if (this.DEBUG) { this._debug( `Save account address=${address} nonce=${account.nonce} balance=${ account.balance } contract=${account.isContract() ? 'yes' : 'no'} empty=${account.isEmpty() ? 'yes' : 'no'}` ) } this._cache.put(address, account) this.touchAccount(address) } /** * Deletes an account from state under the provided `address`. The account will also be removed from the state trie. * @param address - Address of the account which should be deleted */ async deleteAccount(address: Address) { if (this.DEBUG) { this._debug(`Delete account ${address}`) } this._cache.del(address) this.touchAccount(address) } /** * Marks an account as touched, according to the definition * in [EIP-158](https://eips.ethereum.org/EIPS/eip-158). * This happens when the account is triggered for a state-changing * event. Touched accounts that are empty will be cleared * at the end of the tx. */ touchAccount(address: Address): void { this._touched.add(address.buf.toString('hex')) this._customTouched.add(address.buf.toString('hex')) } abstract putContractCode(address: Address, value: Buffer): Promise<void> abstract getContractStorage(address: Address, key: Buffer): Promise<Buffer> abstract putContractStorage(address: Address, key: Buffer, value: Buffer): Promise<void> /** * Caches the storage value associated with the provided `address` and `key` * on first invocation, and returns the cached (original) value from then * onwards. This is used to get the original value of a storage slot for * computing gas costs according to EIP-1283. * @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. */ async getOriginalContractStorage(address: Address, key: Buffer): Promise<Buffer> { if (key.length !== 32) { throw new Error('Storage key must be 32 bytes long') } const addressHex = address.buf.toString('hex') const keyHex = key.toString('hex') let map: Map<AddressHex, Buffer> if (!this._originalStorageCache.has(addressHex)) { map = new Map() this._originalStorageCache.set(addressHex, map) } else { map = this._originalStorageCache.get(addressHex)! } if (map.has(keyHex)) { return map.get(keyHex)! } else { const current = await this.getContractStorage(address, key) map.set(keyHex, current) return current } } /** * Clears the original storage cache. Refer to {@link StateManager.getOriginalContractStorage} * for more explanation. */ _clearOriginalStorageCache(): void { this._originalStorageCache = new Map() } /** * Clears the original storage cache. Refer to {@link StateManager.getOriginalContractStorage} * for more explanation. Alias of the internal {@link StateManager._clearOriginalStorageCache} */ clearOriginalStorageCache(): void { this._clearOriginalStorageCache() } /** * Checkpoints the current state of the StateManager instance. * State changes that follow can then be committed by calling * `commit` or `reverted` by calling rollback. * * Partial implementation, called from the subclass. */ async checkpoint(): Promise<void> { this._cache.checkpoint() this._touchedStack.push(new Set(Array.from(this._touched))) this._accessedStorage.push(new Map()) this._checkpointCount++ } /** * Merges a storage map into the last item of the accessed storage stack */ private _accessedStorageMerge( storageList: Map<string, Set<string>>[], storageMap: Map<string, Set<string>> ) { const mapTarget = storageList[storageList.length - 1] if (mapTarget) { // Note: storageMap is always defined here per definition (TypeScript cannot infer this) storageMap?.forEach((slotSet: Set<string>, addressString: string) => { const addressExists = mapTarget.get(addressString) if (!addressExists) { mapTarget.set(addressString, new Set()) } const storageSet = mapTarget.get(addressString) slotSet.forEach((value: string) => { storageSet!.add(value) }) }) } } /** * Commits the current change-set to the instance since the * last call to checkpoint. * * Partial implementation, called from the subclass. */ async commit(): Promise<void> { // setup cache checkpointing this._cache.commit() this._touchedStack.pop() this._checkpointCount-- // Copy the contents of the map of the current level to a map higher. const storageMap = this._accessedStorage.pop() if (storageMap) { this._accessedStorageMerge(this._accessedStorage, storageMap) } if (this._checkpointCount === 0) { await this._cache.flush() this._clearOriginalStorageCache() } } /** * Reverts the current change-set to the instance since the * last call to checkpoint. * * Partial implementation , called from the subclass. */ async revert(): Promise<void> { // setup cache checkpointing this._cache.revert() const lastItem = this._accessedStorage.pop() if (lastItem) { this._accessedStorageReverted.push(lastItem) } const touched = this._touchedStack.pop() if (!touched) { throw new Error('Reverting to invalid state checkpoint failed') } // Exceptional case due to consensus issue in Geth and Parity. // See [EIP issue #716](https://github.com/ethereum/EIPs/issues/716) for context. // The RIPEMD precompile has to remain *touched* even when the call reverts, // and be considered for deletion. if (this._touched.has(ripemdPrecompileAddress)) { touched.add(ripemdPrecompileAddress) } this._touched = touched this._checkpointCount-- if (this._checkpointCount === 0) { await this._cache.flush() this._clearOriginalStorageCache() } } abstract hasGenesisState(): Promise<boolean> /** * Generates a canonical genesis state on the instance based on the * configured chain parameters. Will error if there are uncommitted * checkpoints on the instance. */ async generateCanonicalGenesis(): Promise<void> { if (this._checkpointCount !== 0) { throw new Error('Cannot create genesis state with uncommitted checkpoints') } const genesis = await this.hasGenesisState() if (!genesis) { await this.generateGenesis(this._common.genesisState()) } } /** * Initializes the provided genesis state into the state trie * @param initState address -> balance | [balance, code, storage] */ async generateGenesis(initState: any): Promise<void> { if (this._checkpointCount !== 0) { throw new Error('Cannot create genesis state with uncommitted checkpoints') } if (this.DEBUG) { this._debug(`Save genesis state into the state trie`) } const addresses = Object.keys(initState) for (const address of addresses) { const addr = Address.fromString(address) const state = initState[address] if (!Array.isArray(state)) { // Prior format: address -> balance const account = Account.fromAccountData({ balance: state }) await this.putAccount(addr, account) } else { // New format: address -> [balance, code, storage] const [balance, code, storage] = state const account = Account.fromAccountData({ balance }) await this.putAccount(addr, account) if (code) { await this.putContractCode(addr, toBuffer(code)) } if (storage) { for (const [key, value] of Object.values(storage) as [string, string][]) { await this.putContractStorage(addr, toBuffer(key), toBuffer(value)) } } } } await this._cache.flush() } /** * Checks if the `account` corresponding to `address` * is empty or non-existent as defined in * EIP-161 (https://eips.ethereum.org/EIPS/eip-161). * @param address - Address to check */ async accountIsEmpty(address: Address): Promise<boolean> { const account = await this.getAccount(address) return account.isEmpty() } /** * Removes accounts form the state trie that have been touched, * as defined in EIP-161 (https://eips.ethereum.org/EIPS/eip-161). */ async cleanupTouchedAccounts(): Promise<void> { if (this._common.gteHardfork('spuriousDragon')) { const touchedArray = Array.from(this._touched) for (const addressHex of touchedArray) { const address = new Address(Buffer.from(addressHex, 'hex')) const empty = await this.accountIsEmpty(address) if (empty) { this._cache.del(address) if (this.DEBUG) { this._debug(`Cleanup touched account address=${address} (>= SpuriousDragon)`) } } } } this._touched.clear() } /** EIP-2929 logic * This should only be called from within the EVM */ /** * Returns true if the address is warm in the current context * @param address - The address (as a Buffer) to check */ isWarmedAddress(address: Buffer): boolean { for (let i = this._accessedStorage.length - 1; i >= 0; i--) { const currentMap = this._accessedStorage[i] if (currentMap.has(address.toString('hex'))) { return true } } return false } /** * Add a warm address in the current context * @param address - The address (as a Buffer) to check */ addWarmedAddress(address: Buffer): void { const key = address.toString('hex') const storageSet = this._accessedStorage[this._accessedStorage.length - 1].get(key) if (!storageSet) { const emptyStorage = new Set() this._accessedStorage[this._accessedStorage.length - 1].set(key, emptyStorage) } } /** * Returns true if the slot of the address is warm * @param address - The address (as a Buffer) to check * @param slot - The slot (as a Buffer) to check */ isWarmedStorage(address: Buffer, slot: Buffer): boolean { const addressKey = address.toString('hex') const storageKey = slot.toString('hex') for (let i = this._accessedStorage.length - 1; i >= 0; i--) { const currentMap = this._accessedStorage[i] if (currentMap.has(addressKey) && currentMap.get(addressKey)!.has(storageKey)) { return true } } return false } /** * Mark the storage slot in the address as warm in the current context * @param address - The address (as a Buffer) to check * @param slot - The slot (as a Buffer) to check */ addWarmedStorage(address: Buffer, slot: Buffer): void { const addressKey = address.toString('hex') let storageSet = this._accessedStorage[this._accessedStorage.length - 1].get(addressKey) if (!storageSet) { storageSet = new Set() this._accessedStorage[this._accessedStorage.length - 1].set(addressKey, storageSet!) } storageSet!.add(slot.toString('hex')) } /** * Clear the warm accounts and storage. To be called after a transaction finished. * @param boolean - If true, returns an EIP-2930 access list generated */ clearWarmedAccounts(): void { this._accessedStorage = [new Map()] this._accessedStorageReverted = [new Map()] } /** * Generates an EIP-2930 access list * * Note: this method is not yet part of the {@link StateManager} interface. * If not implemented, {@link VM.runTx} is not allowed to be used with the * `reportAccessList` option and will instead throw. * * Note: there is an edge case on accessList generation where an * internal call might revert without an accessList but pass if the * accessList is used for a tx run (so the subsequent behavior might change). * This edge case is not covered by this implementation. * * @param addressesRemoved - List of addresses to be removed from the final list * @param addressesOnlyStorage - List of addresses only to be added in case of present storage slots * * @returns - an [@ethereumjs/tx](https://github.com/ethereumjs/ethereumjs-monorepo/packages/tx) `AccessList` */ generateAccessList( addressesRemoved: Address[] = [], addressesOnlyStorage: Address[] = [] ): AccessList { // Merge with the reverted storage list const mergedStorage = [...this._accessedStorage, ...this._accessedStorageReverted] // Fold merged storage array into one Map while (mergedStorage.length >= 2) { const storageMap = mergedStorage.pop() if (storageMap) { this._accessedStorageMerge(mergedStorage, storageMap) } } const folded = new Map([...mergedStorage[0].entries()].sort()) // Transfer folded map to final structure const accessList: AccessList = [] folded.forEach((slots, addressStr) => { const address = Address.fromString(`0x${addressStr}`) const check1 = getActivePrecompiles(this._common).find((a) => a.equals(address)) const check2 = addressesRemoved.find((a) => a.equals(address)) const check3 = addressesOnlyStorage.find((a) => a.equals(address)) !== undefined && slots.size === 0 if (!check1 && !check2 && !check3) { const storageSlots = Array.from(slots) .map((s) => `0x${s}`) .sort() const accessListItem: AccessListItem = { address: `0x${addressStr}`, storageKeys: storageSlots, } accessList!.push(accessListItem) } }) return accessList } }