Skip to content

Commit

Permalink
trie: allow trie put/del ops to skip transforming key for snapsync (#…
Browse files Browse the repository at this point in the history
…2950)

* trie: allow trie put/del ops to skip transforming key

* add ability to test against the synced snap trie

* fix spec

* match entire state

* add spec to trie test

* fix and edge case

---------

Co-authored-by: Scorbajio <[email protected]>
  • Loading branch information
g11tech and scorbajio authored Aug 13, 2023
1 parent 1f73936 commit 125a696
Showing 9 changed files with 148 additions and 66 deletions.
13 changes: 11 additions & 2 deletions packages/client/src/sync/fetcher/accountfetcher.ts
Original file line number Diff line number Diff line change
@@ -63,19 +63,22 @@ export type FetcherDoneFlags = {
trieNodeFetcherDone: boolean
eventBus?: EventBusType | undefined
stateRoot?: Uint8Array | undefined
stateTrie?: Trie | undefined
}

export function snapFetchersCompleted(
fetcherDoneFlags: FetcherDoneFlags,
fetcherType: Object,
root?: Uint8Array,
trie?: Trie,
eventBus?: EventBusType
) {
switch (fetcherType) {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
case AccountFetcher:
fetcherDoneFlags.accountFetcherDone = true
fetcherDoneFlags.stateRoot = root
fetcherDoneFlags.stateTrie = trie
fetcherDoneFlags.eventBus = eventBus
break
case StorageFetcher:
@@ -94,7 +97,11 @@ export function snapFetchersCompleted(
fetcherDoneFlags.byteCodeFetcherDone &&
fetcherDoneFlags.trieNodeFetcherDone
) {
fetcherDoneFlags.eventBus!.emit(Event.SYNC_SNAPSYNC_COMPLETE, fetcherDoneFlags.stateRoot!)
fetcherDoneFlags.eventBus!.emit(
Event.SYNC_SNAPSYNC_COMPLETE,
fetcherDoneFlags.stateRoot!,
fetcherDoneFlags.stateTrie!
)
}
}

@@ -364,6 +371,7 @@ export class AccountFetcher extends Fetcher<JobTask, AccountData[], AccountData>
this.fetcherDoneFlags,
AccountFetcher,
this.accountTrie.root(),
this.accountTrie,
this.config.events
)

@@ -376,7 +384,8 @@ export class AccountFetcher extends Fetcher<JobTask, AccountData[], AccountData>
const storageFetchRequests = new Set()
const byteCodeFetchRequests = new Set<Uint8Array>()
for (const account of result) {
await this.accountTrie.put(account.hash, accountBodyToRLP(account.body))
// what we have is hashed account and not its pre-image, so we skipKeyTransform
await this.accountTrie.put(account.hash, accountBodyToRLP(account.body), true)

// build record of accounts that need storage slots to be fetched
const storageRoot: Uint8Array = account.body[2]
5 changes: 4 additions & 1 deletion packages/client/src/sync/fetcher/storagefetcher.ts
Original file line number Diff line number Diff line change
@@ -394,17 +394,20 @@ export class StorageFetcher extends Fetcher<JobTask, StorageData[][], StorageDat
return
}
let slotCount = 0
const storagePromises: Promise<unknown>[] = []
result[0].map((slotArray, i) => {
const accountHash = result.requests[i].accountHash
const storageTrie =
this.accountToStorageTrie.get(bytesToUnprefixedHex(accountHash)) ??
new Trie({ useKeyHashing: true })
for (const slot of slotArray as any) {
slotCount++
void storageTrie.put(slot.hash, slot.body)
// what we have is hashed account and not its pre-image, so we skipKeyTransform
storagePromises.push(storageTrie.put(slot.hash, slot.body, true))
}
this.accountToStorageTrie.set(bytesToUnprefixedHex(accountHash), storageTrie)
})
await Promise.all(storagePromises)
this.debug(`Stored ${slotCount} slot(s)`)
} catch (err) {
this.debug(err)
12 changes: 6 additions & 6 deletions packages/client/src/sync/fetcher/trienodefetcher.ts
Original file line number Diff line number Diff line change
@@ -337,23 +337,23 @@ export class TrieNodeFetcher extends Fetcher<JobTask, Uint8Array[], Uint8Array>
})
}
}
await storageTrie.batch(storageTrieOps)
await storageTrie.batch(storageTrieOps, true)
await storageTrie.persistRoot()
const a = Account.fromRlpSerializedAccount(node.value())
this.debug(
`calculated and actual storage roots bellow\nactual ${bytesToHex(
`Stored storageTrie with root actual=${bytesToHex(
storageTrie.root()
)} - expected ${bytesToHex(a.storageRoot)}`
)} expected=${bytesToHex(a.storageRoot)}`
)
}
}
}
await this.accountTrie.batch(ops)
await this.accountTrie.batch(ops, true)
await this.accountTrie.persistRoot()
this.debug(
`calculated and actual account roots bellow\nactual ${bytesToHex(
`Stored accountTrie with root actual=${bytesToHex(
this.accountTrie.root()
)} - expected ${bytesToHex(this.root)}`
)} expected=${bytesToHex(this.root)}`
)
}
} catch (e) {
3 changes: 2 additions & 1 deletion packages/client/src/types.ts
Original file line number Diff line number Diff line change
@@ -4,6 +4,7 @@ import type { SyncMode } from '.'
import type { Peer } from './net/peer'
import type { Server } from './net/server'
import type { Block, BlockHeader } from '@ethereumjs/block'
import type { Trie } from '@ethereumjs/trie'
import type { Address } from '@ethereumjs/util'
import type { Multiaddr } from 'multiaddr'

@@ -39,7 +40,7 @@ export interface EventParams {
[Event.SYNC_FETCHED_BLOCKS]: [blocks: Block[]]
[Event.SYNC_FETCHED_HEADERS]: [headers: BlockHeader[]]
[Event.SYNC_SYNCHRONIZED]: [chainHeight: bigint]
[Event.SYNC_SNAPSYNC_COMPLETE]: [stateRoot: Uint8Array]
[Event.SYNC_SNAPSYNC_COMPLETE]: [stateRoot: Uint8Array, accountTrie: Trie]
[Event.SYNC_ERROR]: [syncError: Error]
[Event.SYNC_FETCHER_ERROR]: [fetchError: Error, task: any, peer: Peer | null | undefined]
[Event.PEER_CONNECTED]: [connectedPeer: Peer]
148 changes: 100 additions & 48 deletions packages/client/test/sim/snapsync.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { Common } from '@ethereumjs/common'
import { bytesToHex, hexToBytes, parseGethGenesisState, privateToAddress } from '@ethereumjs/util'
import { DefaultStateManager } from '@ethereumjs/statemanager'
import {
Address,
bytesToHex,
hexToBytes,
parseGethGenesisState,
privateToAddress,
} from '@ethereumjs/util'
import debug from 'debug'
import { Client } from 'jayson/promise'
import { assert, describe, it } from 'vitest'
@@ -19,17 +26,27 @@ import {

import type { EthereumClient } from '../../src/client'
import type { RlpxServer } from '../../src/net/server'
import type { Trie } from '@ethereumjs/trie'

const pkey = hexToBytes('0xae557af4ceefda559c924516cabf029bedc36b68109bf8d6183fe96e04121f4e')
const sender = bytesToHex(privateToAddress(pkey))
const client = Client.http({ port: 8545 })

const network = 'mainnet'
const networkJson = require(`./configs/${network}.json`)
const common = Common.fromGethGenesis(networkJson, { chain: network })
const customGenesisState = parseGethGenesisState(networkJson)

const pkey = hexToBytes('0xae557af4ceefda559c924516cabf029bedc36b68109bf8d6183fe96e04121f4e')
// 0x97C9B168C5E14d5D369B6D88E9776E5B7b11dcC1
const sender = bytesToHex(privateToAddress(pkey))
let senderBalance = BigInt(customGenesisState[sender][0])

let ejsClient: EthereumClient | null = null
let snapCompleted: Promise<unknown> | undefined = undefined
let syncedTrie: Trie | undefined = undefined

// This account doesn't exist in the genesis so starting balance is zero
const EOATransferToAccount = '0x3dA33B9A0894b908DdBb00d96399e506515A1009'
let EOATransferToBalance = BigInt(0)

export async function runTx(data: string, to?: string, value?: bigint) {
return runTxHelper({ client, common, sender, pkey }, data, to, value)
@@ -67,38 +84,31 @@ describe('simple mainnet test run', async () => {
it.skipIf(process.env.ADD_EOA_STATE === undefined)(
'add some EOA transfers',
async () => {
const startBalance = await client.request('eth_getBalance', [
'0x3dA33B9A0894b908DdBb00d96399e506515A1009',
'latest',
])
assert.ok(
startBalance.result !== undefined,
`fetched 0x3dA33B9A0894b908DdBb00d96399e506515A1009 balance=${startBalance.result}`
)
await runTx('', '0x3dA33B9A0894b908DdBb00d96399e506515A1009', 1000000n)
let balance = await client.request('eth_getBalance', [
'0x3dA33B9A0894b908DdBb00d96399e506515A1009',
'latest',
])
let balance = await client.request('eth_getBalance', [EOATransferToAccount, 'latest'])
assert.equal(
EOATransferToBalance,
BigInt(balance.result),
BigInt(startBalance.result) + 1000000n,
'sent a simple ETH transfer'
`fetched ${EOATransferToAccount} balance=${EOATransferToBalance}`
)
await runTx('', '0x3dA33B9A0894b908DdBb00d96399e506515A1009', 1000000n)
balance = await client.request('eth_getBalance', [
'0x3dA33B9A0894b908DdBb00d96399e506515A1009',
'latest',
])
balance = await client.request('eth_getBalance', [
'0x3dA33B9A0894b908DdBb00d96399e506515A1009',
'latest',
])
assert.equal(
BigInt(balance.result),
BigInt(startBalance.result) + 2000000n,
'sent a simple ETH transfer 2x'
balance = await client.request('eth_getBalance', [EOATransferToAccount, 'latest'])

await runTx('', EOATransferToAccount, 1000000n)
EOATransferToBalance += 1000000n

balance = await client.request('eth_getBalance', [EOATransferToAccount, 'latest'])
assert.equal(BigInt(balance.result), EOATransferToBalance, 'sent a simple ETH transfer')
await runTx('', EOATransferToAccount, 1000000n)
EOATransferToBalance += 1000000n

balance = await client.request('eth_getBalance', [EOATransferToAccount, 'latest'])
assert.equal(BigInt(balance.result), EOATransferToBalance, 'sent a simple ETH transfer 2x')

balance = await client.request('eth_getBalance', [sender, 'latest'])
assert.ok(
balance.result !== undefined,
'remaining sender balance after transfers and gas fee'
)
senderBalance = BigInt(balance.result)
},
2 * 60_000
)
@@ -138,31 +148,71 @@ describe('simple mainnet test run', async () => {
it(
'should snap sync and finish',
async () => {
try {
if (ejsClient !== null && snapCompleted !== undefined) {
if (ejsClient !== null && snapCompleted !== undefined) {
// wait on the sync promise to complete if it has been called independently
const snapSyncTimeout = new Promise((_resolve, reject) => setTimeout(reject, 8 * 60_000))
let syncedSnapRoot: Uint8Array | undefined = undefined

try {
// call sync if not has been called yet
void ejsClient.services[0].synchronizer?.sync()
// wait on the sync promise to complete if it has been called independently
const snapSyncTimeout = new Promise((_resolve, reject) => setTimeout(reject, 40000))
try {
await Promise.race([snapCompleted, snapSyncTimeout])
assert.ok(true, 'completed snap sync')
} catch (e) {
assert.fail('could not complete snap sync in 40 seconds')
}
await Promise.race([
snapCompleted.then(([root, trie]) => {
syncedSnapRoot = root
syncedTrie = trie
}),
snapSyncTimeout,
])
await ejsClient.stop()
} else {
assert.fail('ethereumjs client not setup properly for snap sync')
assert.ok(true, 'completed snap sync')
} catch (e) {
assert.fail('could not complete snap sync in 8 minutes')
}

await teardownCallBack()
assert.ok(true, 'network cleaned')
} catch (e) {
assert.fail('network not cleaned properly')
const peerLatest = (await client.request('eth_getBlockByNumber', ['latest', false])).result
const snapRootsMatch =
syncedSnapRoot !== undefined && bytesToHex(syncedSnapRoot) === peerLatest.stateRoot
assert.ok(snapRootsMatch, 'synced stateRoot should match with peer')
} else {
assert.fail('ethereumjs client not setup properly for snap sync')
}
},
10 * 60_000
)

it.skipIf(syncedTrie !== undefined)('should match entire state', async () => {
// update customGenesisState to reflect latest changes and match entire customGenesisState
if (process.env.ADD_EOA_STATE !== undefined) {
customGenesisState[EOATransferToAccount] = [
`0x${EOATransferToBalance.toString(16)}`,
undefined,
undefined,
BigInt(0),
]
customGenesisState[sender][0] = `0x${senderBalance.toString(16)}`
}

const stateManager = new DefaultStateManager({ trie: syncedTrie })

for (const addressString of Object.keys(customGenesisState)) {
const address = Address.fromString(addressString)
const account = await stateManager.getAccount(address)
assert.equal(
account.balance,
BigInt(customGenesisState[addressString][0]),
`${addressString} balance should match`
)
}
})

it('network cleanup', async () => {
try {
await teardownCallBack()
assert.ok(true, 'network cleaned')
} catch (e) {
assert.fail('network not cleaned properly')
}
}, 60_000)
})

async function createSnapClient(common: any, customGenesisState: any, bootnodes: any) {
@@ -189,7 +239,9 @@ async function createSnapClient(common: any, customGenesisState: any, bootnodes:
config.events.once(Event.PEER_CONNECTED, (peer: any) => resolve(peer))
})
const snapSyncCompletedPromise = new Promise((resolve) => {
config.events.once(Event.SYNC_SNAPSYNC_COMPLETE, (stateRoot: any) => resolve(stateRoot))
config.events.once(Event.SYNC_SNAPSYNC_COMPLETE, (stateRoot: Uint8Array, trie: Trie) =>
resolve([stateRoot, trie])
)
})

const ejsInlineClient = await createInlineClient(config, common, customGenesisState)
1 change: 1 addition & 0 deletions packages/client/test/sync/fetcher/accountfetcher.spec.ts
Original file line number Diff line number Diff line change
@@ -227,6 +227,7 @@ describe('[AccountFetcher]', async () => {
fetcherDoneFlags,
AccountFetcher,
fetcher.accountTrie.root(),
fetcher.accountTrie,
config.events
)
const snapSyncTimeout = new Promise((_resolve, reject) => setTimeout(reject, 10000))
14 changes: 7 additions & 7 deletions packages/trie/src/trie.ts
Original file line number Diff line number Diff line change
@@ -182,7 +182,7 @@ export class Trie {
* @param value
* @returns A Promise that resolves once value is stored.
*/
async put(key: Uint8Array, value: Uint8Array): Promise<void> {
async put(key: Uint8Array, value: Uint8Array, skipKeyTransform: boolean = false): Promise<void> {
if (this._opts.useRootPersistence && equalsBytes(key, ROOT_DB_KEY) === true) {
throw new Error(`Attempted to set '${bytesToUtf8(ROOT_DB_KEY)}' key but it is not allowed.`)
}
@@ -193,7 +193,7 @@ export class Trie {
}

await this._lock.acquire()
const appliedKey = this.appliedKey(key)
const appliedKey = skipKeyTransform ? key : this.appliedKey(key)
if (equalsBytes(this.root(), this.EMPTY_TRIE_ROOT) === true) {
// If no root, initialize this trie
await this._createInitialNode(appliedKey, value)
@@ -239,9 +239,9 @@ export class Trie {
* @param key
* @returns A Promise that resolves once value is deleted.
*/
async del(key: Uint8Array): Promise<void> {
async del(key: Uint8Array, skipKeyTransform: boolean = false): Promise<void> {
await this._lock.acquire()
const appliedKey = this.appliedKey(key)
const appliedKey = skipKeyTransform ? key : this.appliedKey(key)
const { node, stack } = await this.findPath(appliedKey)

let ops: BatchDBOp[] = []
@@ -744,15 +744,15 @@ export class Trie {
* await trie.batch(ops)
* @param ops
*/
async batch(ops: BatchDBOp[]): Promise<void> {
async batch(ops: BatchDBOp[], skipKeyTransform?: boolean): Promise<void> {
for (const op of ops) {
if (op.type === 'put') {
if (op.value === null || op.value === undefined) {
throw new Error('Invalid batch db operation')
}
await this.put(op.key, op.value)
await this.put(op.key, op.value, skipKeyTransform)
} else if (op.type === 'del') {
await this.del(op.key)
await this.del(op.key, skipKeyTransform)
}
}
await this.persistRoot()
Loading

0 comments on commit 125a696

Please sign in to comment.