Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/proud-otters-prove.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tevm/state": patch
---

Fork account hydration now falls back to eth_getBalance + eth_getTransactionCount + eth_getCode (pinned to the fork block) on providers that do not serve eth_getProof, such as Monad, ZKsync OS, and Moonbeam. The downgrade is detected once per fork transport and logged; fetched bytecode primes the contract code cache. Account existence detection accepts both zero-hash proof responses and canonical empty hashes produced by scalar hydration.
10 changes: 5 additions & 5 deletions packages/state/src/actions/getAccount.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { bytesToHex } from '@tevm/utils'
import { equalsBytes, KECCAK256_RLP, keccak256 } from '@tevm/utils'
import { fromRlpSerializedAccount } from '../utils/accountHelpers.js'
import { getAccountFromProvider } from './getAccountFromProvider.js'
import { resolveForkBlockTag } from './resolveForkBlockTag.js'

const EMPTY_CODE_HASH = '0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470'
const EMPTY_STORAGE_ROOT = '0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421'
const ZERO_HASH = new Uint8Array(32)
const EMPTY_CODE_HASH = keccak256(new Uint8Array(), 'bytes')

/**
* Gets the account corresponding to the provided `address`.
Expand Down Expand Up @@ -69,8 +69,8 @@ export const getAccount =
if (
account.nonce === 0n &&
account.balance === 0n &&
bytesToHex(account.codeHash) === EMPTY_CODE_HASH &&
bytesToHex(account.storageRoot) === EMPTY_STORAGE_ROOT
(equalsBytes(account.codeHash, ZERO_HASH) || equalsBytes(account.codeHash, EMPTY_CODE_HASH)) &&
(equalsBytes(account.storageRoot, ZERO_HASH) || equalsBytes(account.storageRoot, KECCAK256_RLP))
) {
// Store empty account in both caches
accounts.put(address, undefined)
Expand Down
10 changes: 7 additions & 3 deletions packages/state/src/actions/getAccount.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const mockProof = {
}
const emptyCodeHash = '0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470'
const emptyStorageRoot = '0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421'
const zeroHash = `0x${'00'.repeat(32)}`
const createMockForkTransport = () => ({
request: vi.fn(async ({ method }: { method: string }) => {
if (method === 'eth_getBlockByNumber') {
Expand Down Expand Up @@ -179,16 +180,19 @@ describe(`${getAccount.name} forking`, () => {
expect(baseState.caches.accounts.get(emptyAddress)?.accountRLP).toBeUndefined()
})

it('Should handle empty accounts from remote provider', async () => {
it.each([
['all-zero hashes', zeroHash, zeroHash],
['canonical empty hashes', emptyCodeHash, emptyStorageRoot],
])('Should handle empty accounts from remote provider with %s', async (_encoding, codeHash, storageRoot): Promise<void> => {
// Create an address for testing
const testAddress = createAddress('0x1234567890123456789012345678901234567890')

// Mock the getAccountFromProvider to return an empty account
const mockEmptyAccount = createAccount({
balance: 0n,
nonce: 0n,
codeHash: hexToBytes(emptyCodeHash),
storageRoot: hexToBytes(emptyStorageRoot),
codeHash: hexToBytes(codeHash),
storageRoot: hexToBytes(storageRoot),
})

const mockGetAccountFromProvider = vi.spyOn(getAccountFromProviderModule, 'getAccountFromProvider')
Expand Down
99 changes: 86 additions & 13 deletions packages/state/src/actions/getAccountFromProvider.js
Original file line number Diff line number Diff line change
@@ -1,27 +1,100 @@
import { toBytes } from '@tevm/utils'
import { hexToBytes, keccak256, toBytes } from '@tevm/utils'
import { fromAccountData } from '../utils/accountHelpers.js'
import { getForkBlockTag } from './getForkBlockTag.js'
import { getForkClient } from './getForkClient.js'

/**
* Retrieves an account from the provider and stores in the local trie
* Fork transports that do not serve eth_getProof (e.g. Monad, ZKsync OS, Moonbeam).
* Keyed by transport identity, which deepCopy/shallowCopy preserve, so the
* downgrade is detected once per provider and survives state-manager copies.
* @type {WeakMap<object, true>}
*/
const proofUnsupportedTransports = new WeakMap()

/**
* Returns true for JSON-RPC errors meaning the provider does not serve the
* method, checked across the cause chain via viem's `BaseError.walk`.
* Duck-typed rather than `instanceof BaseError` so errors constructed by a
* duplicate viem install still match. Uncoded errors are never treated as
* capability failures.
* @param {unknown} err
* @returns {boolean}
*/
const isMethodUnavailableError = (err) => {
/** @param {unknown} node @returns {boolean} */
const isMethodUnavailable = (node) => {
const code = /** @type {{code?: unknown}} */ (node)?.code
if (code === -32601 || code === -32004) return true
return (
code === -32600 &&
/not (available|found|supported)|unavailable/i.test(
String(/** @type {{message?: unknown}} */ (node)?.message ?? ''),
)
)
}
const walk = /** @type {{walk?: (fn: (err: unknown) => boolean) => unknown}} */ (err)?.walk
return typeof walk === 'function' ? walk.call(err, isMethodUnavailable) !== null : isMethodUnavailable(err)
}

/**
* Retrieves an account from the provider and stores in the local trie.
*
* Hydrates via a single empty-storageKeys eth_getProof. Providers that do not
* serve eth_getProof are downgraded once per transport to concurrent
* eth_getBalance + eth_getTransactionCount + eth_getCode pinned to the same
* fork block, with codeHash computed locally and storageRoot defaulting to the
* canonical empty trie root (never read by EVM execution).
* @param {import('../BaseState.js').BaseState} baseState
* @returns {(address: import('@tevm/utils').EthjsAddress) => Promise<import('@tevm/utils').EthjsAccount>}
* @private
*/
export const getAccountFromProvider = (baseState) => async (address) => {
const client = getForkClient(baseState)
const blockTag = getForkBlockTag(baseState)
const accountData = await client.getProof({
address: /** @type {import('@tevm/utils').Address}*/ (address.toString()),
storageKeys: [],
...blockTag,
})
const account = fromAccountData({
balance: BigInt(accountData.balance),
nonce: BigInt(accountData.nonce),
codeHash: toBytes(accountData.codeHash),
storageRoot: toBytes(accountData.storageHash),
const addressHex = /** @type {import('@tevm/utils').Address}*/ (address.toString())
const transport = /** @type {object | undefined} */ (baseState.options.fork?.transport)

if (transport === undefined || !proofUnsupportedTransports.has(transport)) {
try {
const accountData = await client.getProof({
address: addressHex,
storageKeys: [],
...blockTag,
})
return fromAccountData({
balance: BigInt(accountData.balance),
nonce: BigInt(accountData.nonce),
codeHash: toBytes(accountData.codeHash),
storageRoot: toBytes(accountData.storageHash),
})
} catch (err) {
if (!isMethodUnavailableError(err)) throw err
if (transport !== undefined) proofUnsupportedTransports.set(transport, true)
baseState.logger.warn(
{ address: addressHex, error: /** @type {Error} */ (err).message },
'eth_getProof is not served by the fork provider; permanently falling back to eth_getBalance/eth_getTransactionCount/eth_getCode for account hydration on this transport',
)
}
}

const [balance, nonce, code] = await Promise.all([
client.getBalance({ address: addressHex, ...blockTag }),
client.getTransactionCount({ address: addressHex, ...blockTag }),
client.getCode({ address: addressHex, ...blockTag }),
])

// Prime both code caches so getContractCode skips its own eth_getCode; the
// main cache is the source of truth for local overrides so never overwrite it
const codeBytes = hexToBytes(code ?? '0x')
if (!baseState.caches.contracts.has(address)) {
baseState.caches.contracts.put(address, codeBytes)
}
baseState.forkCache.contracts.put(address, codeBytes)

return fromAccountData({
balance,
nonce: BigInt(nonce),
codeHash: keccak256(codeBytes, 'bytes'),
// storageRoot omitted: createAccount defaults it to the canonical empty trie root
})
return account
}
Loading
Loading