@nomicfoundation/ethereumjs-statemanager
Version:
An Ethereum statemanager implementation
655 lines (548 loc) • 21.6 kB
text/typescript
import {
Account,
KECCAK256_NULL_S,
bigIntToBytes,
bytesToBigInt,
bytesToHex,
bytesToInt32,
concatBytes,
equalsBytes,
hexToBytes,
padToEven,
setLengthRight,
short,
toBytes,
zeros,
} from '@nomicfoundation/ethereumjs-util'
import { getKey, getStem, verifyUpdate } from '@nomicfoundation/ethereumjs-verkle'
import debugDefault from 'debug'
import { keccak256 as bufferKeccak256 } from 'ethereum-cryptography/keccak.js'
import { AccountCache, CacheType, StorageCache } from './cache/index.js'
import { OriginalStorageCache } from './cache/originalStorageCache.js'
import type { DefaultStateManager } from './stateManager.js'
import type { VerkleExecutionWitness } from '@nomicfoundation/ethereumjs-block'
import type {
AccountFields,
Common,
EVMStateManagerInterface,
Proof,
StorageDump,
StorageRange,
} from '@nomicfoundation/ethereumjs-common'
import type { Address, PrefixedHexString } from '@nomicfoundation/ethereumjs-util'
function keccak256(msg: Uint8Array): Uint8Array {
return new Uint8Array(bufferKeccak256(Buffer.from(msg)))
}
const { debug: createDebugLogger } = debugDefault
const debug = createDebugLogger('statemanager:verkle')
export interface VerkleState {
[key: PrefixedHexString]: PrefixedHexString
}
export interface EncodedVerkleProof {
[key: PrefixedHexString]: PrefixedHexString
}
type CacheOptions = {
/**
* Allows for cache deactivation
*
* Depending on the use case and underlying datastore (and eventual concurrent cache
* mechanisms there), usage with or without cache can be faster
*
* Default: false
*/
deactivate?: boolean
/**
* Cache type to use.
*
* Available options:
*
* ORDERED_MAP: Cache with no fixed upper bound and dynamic allocation,
* use for dynamic setups like testing or similar.
*
* LRU: LRU cache with pre-allocation of memory and a fixed size.
* Use for larger and more persistent caches.
*/
type?: CacheType
/**
* Size of the cache (only for LRU cache)
*
* Default: 100000 (account cache) / 20000 (storage cache)
*
* Note: the cache/trie interplay mechanism is designed in a way that
* the theoretical number of max modified accounts between two flush operations
* should be smaller than the cache size, otherwise the cache will "forget" the
* old modifications resulting in an incomplete set of trie-flushed accounts.
*/
size?: number
}
type CacheSettings = {
deactivate: boolean
type: CacheType
size: number
}
/**
* Options dictionary.
*/
export interface StatelessVerkleStateManagerOpts {
accountCacheOpts?: CacheOptions
/**
* The common to use
*/
common?: Common
storageCacheOpts?: CacheOptions
}
/**
* Tree key constants.
*/
const VERSION_LEAF_KEY = toBytes(0)
const BALANCE_LEAF_KEY = toBytes(1)
const NONCE_LEAF_KEY = toBytes(2)
const CODE_KECCAK_LEAF_KEY = toBytes(3)
const CODE_SIZE_LEAF_KEY = toBytes(4)
const HEADER_STORAGE_OFFSET = 64
const CODE_OFFSET = 128
const VERKLE_NODE_WIDTH = 256
const MAIN_STORAGE_OFFSET = 256 ** 31
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
/**
* 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 implements EVMStateManagerInterface {
_accountCache?: AccountCache
_storageCache?: StorageCache
_codeCache: { [key: string]: Uint8Array }
originalStorageCache: OriginalStorageCache
protected readonly _accountCacheSettings: CacheSettings
protected readonly _storageCacheSettings: CacheSettings
/**
* 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
private _executionWitness?: VerkleExecutionWitness
private _proof: Uint8Array | undefined
// State along execution (should update)
private _state: VerkleState = {}
// Post-state provided from the executionWitness.
// Should not update. Used for comparing our computed post-state with the canonical one.
private _postState: VerkleState = {}
// Checkpointing
private _checkpoints: VerkleState[] = []
private keccakFunction: Function
/**
* Instantiate the StateManager interface.
*/
constructor(opts: StatelessVerkleStateManagerOpts = {}) {
this._accountCacheSettings = {
deactivate: opts.accountCacheOpts?.deactivate ?? false,
type: opts.accountCacheOpts?.type ?? CacheType.ORDERED_MAP,
size: opts.accountCacheOpts?.size ?? 100000,
}
if (!this._accountCacheSettings.deactivate) {
this._accountCache = new AccountCache({
size: this._accountCacheSettings.size,
type: this._accountCacheSettings.type,
})
}
this._storageCacheSettings = {
deactivate: opts.storageCacheOpts?.deactivate ?? false,
type: opts.storageCacheOpts?.type ?? CacheType.ORDERED_MAP,
size: opts.storageCacheOpts?.size ?? 20000,
}
if (!this._storageCacheSettings.deactivate) {
this._storageCache = new StorageCache({
size: this._storageCacheSettings.size,
type: this._storageCacheSettings.type,
})
}
this.originalStorageCache = new OriginalStorageCache(this.getContractStorage.bind(this))
this._codeCache = {}
this.keccakFunction = opts.common?.customCrypto.keccak256 ?? keccak256
// 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
/*
* For a custom StateManager implementation adopt these
* callbacks passed to the `Cache` instantiated to perform
* the `get`, `put` and `delete` operations with the
* desired backend.
*/
// const getCb: get = async (address) => {
// return undefined
// }
// const putCb: put = async (keyBuf, accountRlp) => {}
// const deleteCb = async (keyBuf: Uint8Array) => {}
// this._cache = new Cache({ get, putCb, deleteCb })
}
async getTransitionStateRoot(_: DefaultStateManager, __: Uint8Array): Promise<Uint8Array> {
throw Error('not implemented')
}
public initVerkleExecutionWitness(executionWitness?: VerkleExecutionWitness | null) {
if (executionWitness === null || executionWitness === undefined) {
throw Error(`Invalid executionWitness=${executionWitness} for initVerkleExecutionWitness`)
}
this._executionWitness = executionWitness
this._proof = executionWitness.verkleProof as unknown as Uint8Array
// 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(suffix.toString(16))}`
// TODO: Evaluate if we should store and handle null in a special way
// Currently we are replacing `null` with 0x00..00 (32 bytes) [expect for codeHash, suffix 3, where we use the empty codeHash] for simplifying handling and comparisons
// Also, test data has been inconsistent in this regard, so this simplifies things while things get more standardized
if (Number(suffix) === 3) {
return { [key]: currentValue ?? KECCAK256_NULL_S }
}
return {
[key]: currentValue ?? bytesToHex(zeros(32)),
}
})
return suffixDiffPairs
})
const preState = preStateRaw.reduce((prevValue, currentValue) => {
const acc = { ...prevValue, ...currentValue }
return acc
}, {})
this._state = preState
const postStateRaw = executionWitness.stateDiff.flatMap(({ stem, suffixDiffs }) => {
const suffixDiffPairs = suffixDiffs.map(({ newValue, suffix }) => {
const key = `${stem}${padToEven(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
debug('initVerkleExecutionWitness preState', this._state)
debug('initVerkleExecutionWitness postState', this._postState)
}
getTreeKeyForVersion(stem: Uint8Array) {
return getKey(stem, VERSION_LEAF_KEY)
}
getTreeKeyForBalance(stem: Uint8Array) {
return getKey(stem, BALANCE_LEAF_KEY)
}
getTreeKeyForNonce(stem: Uint8Array) {
return getKey(stem, NONCE_LEAF_KEY)
}
getTreeKeyForCodeHash(stem: Uint8Array) {
return getKey(stem, CODE_KECCAK_LEAF_KEY)
}
getTreeKeyForCodeSize(stem: Uint8Array) {
return getKey(stem, CODE_SIZE_LEAF_KEY)
}
getTreeKeyForCodeChunk(address: Address, chunkId: number) {
return getKey(
getStem(address, Math.floor((CODE_OFFSET + chunkId) / VERKLE_NODE_WIDTH)),
toBytes((CODE_OFFSET + chunkId) % VERKLE_NODE_WIDTH)
)
}
chunkifyCode(code: Uint8Array) {
// Pad code to multiple of 31 bytes
if (code.length % 31 !== 0) {
const paddingLength = 31 - (code.length % 31)
code = setLengthRight(code, code.length + paddingLength)
}
throw new Error('Not implemented')
}
getTreeKeyForStorageSlot(address: Address, storageKey: number) {
let position: number
if (storageKey < CODE_OFFSET - HEADER_STORAGE_OFFSET) {
position = HEADER_STORAGE_OFFSET + storageKey
} else {
position = MAIN_STORAGE_OFFSET + storageKey
}
return getKey(
getStem(address, Math.floor(position / VERKLE_NODE_WIDTH)),
toBytes(position % VERKLE_NODE_WIDTH)
)
}
/**
* Copies the current instance of the `StateManager`
* at the last fully committed point, i.e. as if all current
* checkpoints were reverted.
*/
shallowCopy(): EVMStateManagerInterface {
const stateManager = new StatelessVerkleStateManager()
stateManager.initVerkleExecutionWitness(this._executionWitness!)
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 putContractCode(address: Address, value: Uint8Array): Promise<void> {
const stem = getStem(address, 0)
const codeHashKey = this.getTreeKeyForCodeHash(stem)
const codeHash = bytesToHex(this.keccakFunction(value))
this._state[bytesToHex(codeHashKey)] = codeHash
if (this.DEBUG) {
debug(`putContractCode address=${address.toString()} value=${short(value)}`)
}
if (KECCAK256_NULL_S === codeHash) {
// If the code hash is the null hash, no code has to be stored
return
}
// TODO: Slice the code into chunks and add them to the state
throw new Error('Not implemented')
}
/**
* 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 getContractCode(address: Address): Promise<Uint8Array> {
if (this.DEBUG) {
debug(`getContractCode address=${address.toString()}`)
}
// Get the contract code size
const codeSizeKey = this.getTreeKeyForCodeSize(getStem(address, 0))
const codeSizeLE = hexToBytes(this._state[bytesToHex(codeSizeKey)] ?? '0x')
// Calculate number of chunks
const chunks = Math.ceil(bytesToInt32(codeSizeLE, true) / 32)
const retrievedChunks: Uint8Array[] = []
// Retrieve all code chunks
for (let chunkId = 0; chunkId < chunks; chunkId++) {
retrievedChunks.push(this.getTreeKeyForCodeChunk(address, chunkId))
}
// Aggregate code chunks
const code = concatBytes(...retrievedChunks)
// Return code chunks
return code
}
/**
* 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 getContractStorage(address: Address, key: Uint8Array): Promise<Uint8Array> {
const storageKey = this.getTreeKeyForStorageSlot(address, Number(bytesToHex(key)))
const storage = toBytes(this._state[bytesToHex(storageKey)])
return storage
}
/**
* 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 putContractStorage(address: Address, key: Uint8Array, value: Uint8Array): Promise<void> {
const storageKey = this.getTreeKeyForStorageSlot(address, Number(bytesToHex(key)))
this._state[bytesToHex(storageKey)] = bytesToHex(value)
}
/**
* Clears all storage entries for the account corresponding to `address`.
* @param address - Address to clear the storage of
*/
async clearContractStorage(address: Address): Promise<void> {
const stem = getStem(address, 0)
const codeHashKey = this.getTreeKeyForCodeHash(stem)
// Update codeHash to `c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470`
this._state[bytesToHex(codeHashKey)] = KECCAK256_NULL_S
// TODO: Clear all storage slots (how?)
}
async getAccount(address: Address): Promise<Account> {
const stem = getStem(address, 0)
const balanceKey = this.getTreeKeyForBalance(stem)
const nonceKey = this.getTreeKeyForNonce(stem)
const codeHashKey = this.getTreeKeyForCodeHash(stem)
const balanceRaw = this._state[bytesToHex(balanceKey)]
const nonceRaw = this._state[bytesToHex(nonceKey)]
const codeHash = this._state[bytesToHex(codeHashKey)]
const account = Account.fromAccountData({
balance:
typeof balanceRaw === 'string' ? bytesToBigInt(hexToBytes(balanceRaw), true) : undefined,
nonce: typeof nonceRaw === 'string' ? bytesToBigInt(hexToBytes(nonceRaw), true) : undefined,
codeHash,
})
if (this.DEBUG) {
debug(
`getAccount address=${address.toString()} stem=${short(stem)} balance=${
account.balance
} nonce=${account.nonce} codeHash=${short(account.codeHash)} storageHash=${short(
account.storageRoot
)}`
)
}
return account
}
async putAccount(address: Address, account: Account): Promise<void> {
const stem = getStem(address, 0)
const balanceKey = this.getTreeKeyForBalance(stem)
const nonceKey = this.getTreeKeyForNonce(stem)
const codeHashKey = this.getTreeKeyForCodeHash(stem)
const balanceBuf = setLengthRight(bigIntToBytes(account.balance, true), 32)
const nonceBuf = setLengthRight(bigIntToBytes(account.nonce, true), 32)
this._state[bytesToHex(balanceKey)] = bytesToHex(balanceBuf)
this._state[bytesToHex(nonceKey)] = bytesToHex(nonceBuf)
this._state[bytesToHex(codeHashKey)] = bytesToHex(account.codeHash)
if (this.DEBUG) {
debug(
`putAccount address=${address.toString()} stem=${short(stem)} balance=${
account.balance
} nonce=${account.nonce} codeHash=${short(account.codeHash)} storageHash=${short(
account.storageRoot
)}`
)
}
}
/**
* Deletes an account from state under the provided `address`.
* @param address - Address of the account which should be deleted
*/
async deleteAccount(address: Address) {
this._accountCache!.del(address)
if (!this._storageCacheSettings.deactivate) {
this._storageCache?.clearContractStorage(address)
}
}
async modifyAccountFields(address: Address, accountFields: AccountFields): Promise<void> {
const account = await this.getAccount(address)
account.nonce = accountFields.nonce ?? account.nonce
account.balance = accountFields.balance ?? account.balance
account.storageRoot = accountFields.storageRoot ?? account.storageRoot
account.codeHash = accountFields.codeHash ?? account.codeHash
await this.putAccount(address, account)
}
getProof(_: Address, __: Uint8Array[] = []): Promise<Proof> {
throw new Error('Not implemented yet')
}
async verifyProof(parentVerkleRoot: Uint8Array): Promise<boolean> {
// Implementation: https://github.com/crate-crypto/rust-verkle-wasm/blob/master/src/lib.rs#L45
// The root is the root of the current (un-updated) trie
// The proof is proof of membership of all of the accessed values
// keys_values is a map from the key of the accessed value to a tuple
// the tuple contains the old value and the updated value
//
// This function returns the new root when all of the updated values are applied
const updatedStateRoot: Uint8Array = verifyUpdate(
parentVerkleRoot,
this._proof!, // TODO: Convert this into a Uint8Array ingestible by the method
new Map() // TODO: Generate the keys_values map from the old to the updated value
)
// TODO: Not sure if this should return the updated state Root (current block) or the un-updated one (parent block)
const verkleRoot = await this.getStateRoot()
// Verify that updatedStateRoot matches the state root of the block
return equalsBytes(updatedStateRoot, verkleRoot)
}
// Verifies that the witness post-state matches the computed post-state
verifyPostState(): boolean {
for (const [key, canonicalValue] of Object.entries(this._postState)) {
const computedValue = this._state[key]
if (canonicalValue !== computedValue) {
debug(
`verifyPostState failed for key ${key}. Canonical value: ${canonicalValue}, computed value: ${computedValue}`
)
return false
}
}
return true
}
/**
* 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(): Promise<void> {
this._checkpoints.push(this._state)
this._accountCache?.checkpoint()
this._storageCache?.checkpoint()
}
/**
* Commits the current change-set to the instance since the
* last call to checkpoint.
*/
async commit(): Promise<void> {
this._checkpoints.pop()
this._accountCache!.commit()
}
// TODO
async hasStateRoot(_: Uint8Array): Promise<boolean> {
return true
}
/**
* Reverts the current change-set to the instance since the
* last call to checkpoint.
*/
async revert(): Promise<void> {
// setup trie checkpointing
this._accountCache?.revert()
this._storageCache?.revert()
this._codeCache = {}
}
/**
* Writes all cache items to the trie
*/
async flush(): Promise<void> {}
/**
* Gets the verkle root.
* NOTE: this needs some examination in the code where this is needed
* and if we have the verkle root present
* @returns {Promise<Uint8Array>} - Returns the verkle root of the `StateManager`
*/
async getStateRoot(): Promise<Uint8Array> {
return new Uint8Array(0)
}
/**
* TODO: needed?
* Maybe in this context: reset to original pre state suffice
* @param stateRoot - The verkle root to reset the instance to
*/
async setStateRoot(_: Uint8Array): Promise<void> {}
/**
* Dumps the RLP-encoded storage values for an `account` specified by `address`.
* @param address - The address of the `account` to return storage for
* @returns {Promise<StorageDump>} - The state of the account as an `Object` map.
* Keys are are the storage keys, values are the storage values as strings.
* Both are represented as hex strings without the `0x` prefix.
*/
async dumpStorage(_: Address): Promise<StorageDump> {
throw Error('not implemented')
}
dumpStorageRange(_: Address, __: bigint, ___: number): Promise<StorageRange> {
throw Error('not implemented')
}
/**
* Clears all underlying caches
*/
clearCaches() {
this._accountCache?.clear()
this._storageCache?.clear()
}
generateCanonicalGenesis(_initState: any): Promise<void> {
return Promise.resolve()
}
}