@warptoad/skinny-fat-imt-js 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Trees.ts ADDED
@@ -0,0 +1,738 @@
1
+ import { LeanIMT, type LeanIMTHashFunction } from "@zk-kit/lean-imt";
2
+ import { poseidon2Hash } from "@zkpassport/poseidon2";
3
+ import { getContract, toHex, type Abi, type AbiEvent, type Address, type GetContractReturnType, type Hex, type Log, type PublicClient, type WalletClient } from "viem";
4
+
5
+ import { getInterfaceId, detectSupportedInterfaces, supportsInterface } from "./interfaceId.js";
6
+
7
+ import { minBigInt, queryEventInChunks, queryMultiEventsInChunks, type EventLog, type PostQueryEventFilter } from "./eventScanning.js";
8
+
9
+ // ABIs baked out of the hardhat artifacts by `scripts/genAbis.mjs` — `as const` tuples, so viem
10
+ // infers exactly what the generated `*$Type["abi"]` used to give us, without the published
11
+ // typings depending on hardhat. Regenerate with `pnpm compile`.
12
+ //
13
+ // The four `i*` ones are the read *interfaces*, used only to derive their ERC-165 ids (below)
14
+ // directly from each interface — no need to diff the full contract ABIs.
15
+ import {
16
+ skinnyStorageAbi,
17
+ skinnyEventAbi,
18
+ fatStorageAbi,
19
+ fatEventAbi,
20
+ iimtEventsAbi,
21
+ iSkinnyEventAbi,
22
+ iSkinnyStorageAbi,
23
+ iFatEventAbi,
24
+ iFatStorageAbi,
25
+ } from "./abis.js";
26
+ import { DEPLOY_BLOCK, getDeploymentBlock } from "./config.js";
27
+
28
+ export { skinnyStorageAbi, skinnyEventAbi, fatStorageAbi, fatEventAbi, iimtEventsAbi };
29
+
30
+ export type AnyContract = {
31
+ address: Address
32
+ abi: Abi
33
+ read: Record<string, (...args: any[]) => Promise<any>>
34
+ }
35
+
36
+ type ReadableStorageAbi = readonly [
37
+ ...typeof skinnyStorageAbi,
38
+ ...typeof fatStorageAbi,
39
+ ];
40
+
41
+ type ReadableEventAbi = readonly [
42
+ ...typeof skinnyEventAbi,
43
+ ...typeof fatEventAbi,
44
+ ...typeof iimtEventsAbi,
45
+ ]
46
+
47
+ type ReadableAbi = readonly [...ReadableStorageAbi, ...ReadableEventAbi]
48
+
49
+ /**
50
+ * A single event *entry* of the contract abi, by name. `EventLog`/`PostQueryEventFilter` want one
51
+ * AbiEvent, not a whole abi, and this is the exact same thing `queryEventInChunks` infers for
52
+ * `eventName`, so filters typed with it line up with the query they're passed to.
53
+ */
54
+ type ReadableEvent<TName extends string> = Extract<ReadableAbi[number], AbiEvent & { name: TName }>;
55
+
56
+
57
+ export const readableStorageAbi = [
58
+ ...skinnyStorageAbi,
59
+ ...fatStorageAbi,
60
+ ] satisfies ReadableStorageAbi;
61
+
62
+ export const readableEventAbi = [
63
+ ...skinnyEventAbi,
64
+ ...fatEventAbi,
65
+ ...iimtEventsAbi,
66
+ ] satisfies ReadableEventAbi;
67
+
68
+ export type ReadableContract = GetContractReturnType<ReadableAbi, PublicClient>;
69
+ export type ReadableContractStorage = GetContractReturnType<ReadableStorageAbi, PublicClient>;
70
+ export type ReadableContractEvent = GetContractReturnType<ReadableEventAbi, PublicClient>;
71
+
72
+ export type ReadableContractSkinnyEvent = GetContractReturnType<typeof skinnyEventAbi, PublicClient>;
73
+ export type ReadableContractSkinnyStorage = GetContractReturnType<typeof skinnyStorageAbi, PublicClient>;
74
+ export type ReadableContractFatEvent = GetContractReturnType<typeof fatEventAbi, PublicClient>;
75
+ export type ReadableContractFatStorage = GetContractReturnType<typeof fatStorageAbi, PublicClient>;
76
+
77
+ export const ERC165_IDS = {
78
+ skinnyEvent: getInterfaceId(iSkinnyEventAbi),
79
+ skinnyStorage: getInterfaceId(iSkinnyStorageAbi, [iSkinnyEventAbi]),
80
+ fatEvent: getInterfaceId(iFatEventAbi),
81
+ fatStorage: getInterfaceId(iFatStorageAbi, [iFatEventAbi]),
82
+ } as const satisfies Record<string, Hex>;
83
+
84
+ type IStorageSupport = {
85
+ event: boolean | undefined;
86
+ storage: boolean | undefined;
87
+ };
88
+
89
+ type IFamilySupport = {
90
+ skinny: IStorageSupport;
91
+ fat: IStorageSupport;
92
+ };
93
+
94
+ enum TREE_TYPE {
95
+ UNKNOWN,
96
+ FAT_STORAGE,
97
+ FAT_EVENT,
98
+ SKINNY_STORAGE,
99
+ SKINNY_EVENT,
100
+ NO_INTERFACE
101
+ }
102
+
103
+ export const LeanIMTHashFuncPoseidon2: LeanIMTHashFunction = (a: bigint, b: bigint) => poseidon2Hash([a, b])
104
+
105
+ export type CachedTree = { tree: LeanIMT<bigint>, type: TREE_TYPE, lastSynced: bigint, insertOnlyTree?: boolean }
106
+
107
+ /**
108
+ * A detached LeanIMT holding the same leaves *and* internal nodes as `tree`, so it can be grown
109
+ * without touching the original. Copies the nodes rather than replaying the leaves on purpose:
110
+ * `new LeanIMT(hash, tree.leaves)` would rehash the whole tree, which is the exact cost a caller
111
+ * growing a cached tree is trying to avoid. This is array copies only, no hashing.
112
+ */
113
+ export function copyTree(tree: LeanIMT<bigint>, hashFunc: LeanIMTHashFunction): LeanIMT<bigint> {
114
+ // `_nodes[level][index]` holds every computed node, it isn't in the public typings
115
+ const nodes = (tree as any)._nodes as bigint[][];
116
+ const copy = new LeanIMT(hashFunc);
117
+ (copy as any)._nodes = nodes.map((level) => level.slice());
118
+ return copy
119
+ }
120
+
121
+ export class Trees {
122
+ private trees: { [treeId: Hex]: CachedTree } = {}
123
+ /** last known size per tree, with the block it was read at. See {@link getTreeSizeStorage}. */
124
+ private sizes: { [treeId: Hex]: { blockNumber: bigint, size: bigint } } = {}
125
+
126
+ public contractAddress: Address;
127
+ public contract!: ReadableContract;
128
+ public hashFunc: LeanIMTHashFunction;
129
+ private publicClient: PublicClient;
130
+
131
+ constructor(contractAddress: Address, client: PublicClient, hashFunc = LeanIMTHashFuncPoseidon2) {
132
+ this.contractAddress = contractAddress
133
+ this.publicClient = client
134
+ this.hashFunc = hashFunc;
135
+ }
136
+
137
+ async sync(treeIds: bigint[] | undefined = undefined, { fullNodeMode = true, attemptFastSizeMatch = true, syncToRoot, eventChunkSize = 2000n, storageChunkSize = 2000n, insertOnlyTree, autoDiscovery, hasRepeatedLeafs = true, blockNumber }: { fullNodeMode?: boolean, blockNumber?: bigint, attemptFastSizeMatch?: boolean, syncToRoot?: bigint, eventChunkSize?: bigint, storageChunkSize?: bigint, hasRepeatedLeafs?: boolean, insertOnlyTree?: boolean, autoDiscovery?: boolean | undefined } = {}) {
138
+ let syncedTrees: { [treeId: `0x${string}`]: CachedTree; };
139
+
140
+ // fullNode mode syncs all cached trees, non fullNode mode will auto discover them, so left empty
141
+ const treeIdsNotSet = treeIds === undefined || treeIds.length === 0;
142
+ treeIds = treeIdsNotSet ? fullNodeMode ? (Object.keys(this.trees) as Hex[]).map((v) => BigInt(v)) : [] : treeIds
143
+ if (autoDiscovery && fullNodeMode) { throw new Error(`auto treeId discovery not supported in fullNodeMode, please provide treeIds to sync. Or turn off fullNodeMode: \`Trees.sync([],{fullNodeMode:false})\``) }
144
+ if (fullNodeMode) {
145
+ syncedTrees = await this.syncTreesStorage(
146
+ treeIds,
147
+ { chunkSize: storageChunkSize, attemptFastSizeMatch, blockNumber }
148
+ )
149
+ // syncing to a specific root is not possible with pure storage, so we sync with storage first
150
+ // then syncTreesEvent will look for the root and try to trim off the leaves that happened after the root we want
151
+ // in tree with only inserts this will work and will allows us to get any tree that is up to 1 year old
152
+ // if there are updates or resets, currently syncTreesEvent will have to go down till it found all leaves
153
+ if (syncToRoot !== undefined) {
154
+ syncedTrees = await this.syncTreesEvent(
155
+ treeIds,
156
+ {blockNumber, attemptFastSizeMatch, syncToRoot, chunkSize: eventChunkSize, insertOnlyTree, autoDiscovery, hasRepeatedLeafs }
157
+ )
158
+ }
159
+ } else {
160
+ syncedTrees = await this.syncTreesEvent(
161
+ treeIds,
162
+ { blockNumber, attemptFastSizeMatch, syncToRoot, chunkSize: eventChunkSize, insertOnlyTree, autoDiscovery, hasRepeatedLeafs }
163
+ )
164
+ }
165
+ return syncedTrees;
166
+ }
167
+
168
+ /**
169
+ * Reads trees straight from contract storage, at `blockNumber` (defaults to the safe block).
170
+ *
171
+ * `attemptFastSizeMatch` (default true) bets that the cached tree is still the right *prefix* of the
172
+ * onchain one, so only the difference in size has to be dealt with: read the leaves past the
173
+ * cache's end, or drop the ones past the onchain size. It is a hope, not a promise, so it always
174
+ * ends in a root check — a tree that had an update(), or was reset and regrown with different
175
+ * leaves, fails that check and is re-read in full. Turning it off re-reads every leaf up front.
176
+ */
177
+ async syncTreesStorage(
178
+ treeIds: bigint[] | undefined = undefined,
179
+ { chunkSize = 2000n, attemptFastSizeMatch = true, blockNumber }: { attemptFastSizeMatch?: boolean, chunkSize?: bigint, blockNumber?: bigint } = {}
180
+ ): Promise<{ [treeId: Hex]: CachedTree }> {
181
+ // default syncs all previously synced trees
182
+ treeIds ??= (Object.keys(this.trees) as Hex[]).map((v) => BigInt(v));
183
+ if (treeIds.length === 0) {
184
+ throw new Error(`Nothing to sync. No treeIds in cache and no treeIds provided. Please specify which treeIds to sync. SyncTreesStorage cant automatically find them. If you don't know the treeIds, use syncTreesEvent or maybe the contract using the skinny/fat-imt library if it can provide the treeId.`)
185
+ }
186
+ if (chunkSize <= 0n) {
187
+ throw new Error(`chunkSize has to be at least 1, got: ${chunkSize}`)
188
+ }
189
+ const treeIdsHex = treeIds.map((id) => toHex(id));
190
+
191
+ const chainId = await this.publicClient.getChainId();
192
+ const cachedTrees = treeIdsHex.filter((id) => this.trees[id] !== undefined && this.trees[id].tree.size > 0)
193
+ if (attemptFastSizeMatch === false && cachedTrees.length > 0) {
194
+ console.warn(`treeIds:${cachedTrees} where found in cache from a previous sync. But attemptFastSizeMatch=false so this cache is not tried and every leaf is read again. The attempt is checked against the onchain root, so it can't cache a wrong tree, it only costs a re-read when it doesn't hold.`)
195
+ }
196
+ const identifiedTreesEnt = await Promise.all(treeIdsHex.map(async (id) => [id, await this.getTreeType(id, chainId)])) as [Hex, TREE_TYPE][]
197
+ const unsupportedTypes = [TREE_TYPE.NO_INTERFACE, TREE_TYPE.UNKNOWN, TREE_TYPE.SKINNY_EVENT]
198
+ const unsupportedTrees = identifiedTreesEnt.filter(([id, type]) => unsupportedTypes.includes(type))
199
+ if (unsupportedTrees.length > 0) {
200
+ throw new Error(`Some or more unsupported types found. Types not supported are: ${unsupportedTypes.map((t) => TREE_TYPE[t])}, unsupported treeIds found: ${unsupportedTrees.map(([id, type]) => `id:${id},type:${TREE_TYPE[type]}`)}`)
201
+ }
202
+
203
+ // prevent re-orgs, get a state at a safe block that requires 2/3 of the stake to re-org
204
+ // a blockNumber from the caller is taken as is: older than head needs an archive node, and
205
+ // newer than safe leaves the re-org risk to whoever asked for that block. Like syncToRoot in
206
+ // syncTreesEvent it can also move a cached tree *backwards*, which the trim below handles.
207
+ blockNumber ??= (await this.publicClient.getBlock({ blockTag: "safe" })).number
208
+ const syncedTrees: { [treeId: Hex]: CachedTree } = {}
209
+ for (const treeId of treeIdsHex) {
210
+ const targetSize = await this.getTreeSizeStorage(treeId, chainId, blockNumber)
211
+ const onchainRoot = await this.getRootStorage(treeId, chainId, blockNumber)
212
+ const cached = this.trees[treeId]
213
+ const cacheSize = BigInt(cached ? cached.tree.size : 0)
214
+
215
+ let tree: LeanIMT<bigint> | undefined = undefined
216
+ // leaves the attempt below already read from the contract, covering [readFrom, targetSize).
217
+ // They came from this block, not from the cache, so a failed attempt doesn't invalidate them
218
+ // and the fallback read only has to cover [0, readFrom).
219
+ let newInsertedLeaves: bigint[] = []
220
+ let readFrom = targetSize
221
+ if (attemptFastSizeMatch && cacheSize > 0n) {
222
+ const startTime = performance.now()
223
+ let attempt: LeanIMT<bigint>
224
+ if (cacheSize > targetSize) {
225
+ // the cache is ahead of the onchain tree: either it was synced to a newer block
226
+ // than this one, or a reset() happened. Either way the hope is that the leaves it
227
+ // keeps are the same ones, so only the indexes past targetSize have to go.
228
+ // @TODO trimming rehashes the whole prefix, LeanIMT can't drop leaves and keep
229
+ // its internal nodes. Same trade-off syncTreesEvent's trim makes.
230
+ attempt = new LeanIMT(this.hashFunc, cached.tree.leaves.slice(0, Number(targetSize)))
231
+ } else {
232
+ // the hope is that the tree only grew, so only the indexes the cache is missing
233
+ // are read. An update() below cacheSize is invisible here, the root check catches it.
234
+ // @notice grows a copy, never cached.tree itself: a throw does not undo a mutation,
235
+ // so growing it in place would leave leaves from this block under the block the
236
+ // cache was last synced at, if anything below here fails.
237
+ newInsertedLeaves = await this.readLeavesStorage(treeId, chainId, blockNumber, chunkSize, cacheSize, targetSize)
238
+ readFrom = cacheSize
239
+ attempt = copyTree(cached.tree, this.hashFunc)
240
+ if (newInsertedLeaves.length > 0) {
241
+ attempt.insertMany(newInsertedLeaves)
242
+ }
243
+ }
244
+ // check root match. (attempt.root ?? 0n) <= to also be fine if tree is empty since attempt.root will be undefined
245
+ if ((attempt.root ?? 0n) === onchainRoot) {
246
+ tree = attempt
247
+ } else {
248
+ // the leaves the cache held were not the onchain ones, so an update(), updateMany()
249
+ // or a reset() rewrote them. Only the tree build is wasted, the leaves read above
250
+ // are kept, so the fallback re-reads the cached ones and nothing more.
251
+ console.warn(`failed fast size match on treeId:${treeId}, wasted ${performance.now() - startTime}ms on a tree build. Falling back to reading the ${readFrom} leaves the cache claimed, reusing the ${newInsertedLeaves.length} already read from the contract.`)
252
+ }
253
+ }
254
+
255
+ // attempt failed or was not tried, so read every leaf the attempt didn't already get
256
+ if (tree === undefined) {
257
+ const readLeaves = await this.readLeavesStorage(treeId, chainId, blockNumber, chunkSize, 0n, readFrom)
258
+ tree = new LeanIMT(this.hashFunc, [...readLeaves, ...newInsertedLeaves])
259
+ if ((tree.root ?? 0n) !== onchainRoot) {
260
+ // every leaf came straight from the contract at this block, so this is not a stale
261
+ // cache. Drop what's cached for this tree, the attempt above may have grown it.
262
+ delete this.trees[treeId]
263
+ throw new Error(`Synced treeId:${treeId} to root:${tree.root ?? 0n} at block:${blockNumber}, but the contract reports root:${onchainRoot} for it. Its leaves and its root disagree, so one of the two reads is not what this lib expects.`)
264
+ }
265
+ }
266
+
267
+ this.trees[treeId] = {
268
+ tree: tree,
269
+ type: this.trees[treeId].type, // getTreeType() above put every treeId in the cache
270
+ lastSynced: blockNumber,
271
+ // attemptFastSizeMatch is a guess this sync made, not something it learned about the tree,
272
+ // so whatever is known about updates stays as it was
273
+ insertOnlyTree: this.trees[treeId].insertOnlyTree
274
+ }
275
+ syncedTrees[treeId] = this.trees[treeId]
276
+ }
277
+ return syncedTrees
278
+ }
279
+
280
+ /**
281
+ * The leaves of `[startIndex, endIndex)`, read in chunks of `chunkSize`. Same range as
282
+ * {@link Trees#getLeavesStorage}, which does it in a single call, but split up so a big range
283
+ * doesn't have to fit in one eth_call. What the caller does with them (build a tree, grow one)
284
+ * is up to it.
285
+ */
286
+ private async readLeavesStorage(
287
+ treeId: Hex, chainId: number, blockNumber: bigint, chunkSize: bigint, startIndex: bigint, endIndex: bigint
288
+ ): Promise<bigint[]> {
289
+ const leaves: bigint[] = []
290
+ for (let firstIndex = startIndex; firstIndex < endIndex; firstIndex += chunkSize) {
291
+ const lastIndex = minBigInt(firstIndex + chunkSize, endIndex) // lastIndex is exclusive
292
+ leaves.push(...await this.getLeavesStorage(treeId, chainId, firstIndex, lastIndex, blockNumber))
293
+ }
294
+ return leaves
295
+ }
296
+
297
+ async syncTreesEvent(treeIds: bigint[] | undefined = undefined, {blockNumber, attemptFastSizeMatch = true, syncToRoot, chunkSize = 2000n, insertOnlyTree, autoDiscovery, hasRepeatedLeafs = true }: {blockNumber?:bigint, attemptFastSizeMatch?: boolean, syncToRoot?: bigint, chunkSize?: bigint, hasRepeatedLeafs?: boolean, insertOnlyTree?: boolean, autoDiscovery?: boolean | undefined } = {}) {
298
+ treeIds ??= [];
299
+ if (treeIds.length !== 0 && autoDiscovery === true) {
300
+ throw new Error(`TreeIds where provided while autoDiscovery=true. Either turn off autoDiscovery to only sync those specific ids or set treeIds=undefined to automatically discover and sync all treeId.`)
301
+ }
302
+ if (treeIds.length === 0 && autoDiscovery === false) {
303
+ throw new Error(`Please specify which treeIds to sync. Or set autoDiscovery=true`)
304
+ }
305
+ const chainId = await this.publicClient.getChainId()
306
+ const defaultToAutoDiscover = treeIds.length === 0 && autoDiscovery === undefined && syncToRoot === undefined
307
+ const discoverAllIds = treeIds.length === 0 && autoDiscovery === undefined && syncToRoot === undefined || autoDiscovery === true
308
+ if (autoDiscovery && syncToRoot !== undefined) {
309
+ console.warn(`autoDiscovery was set to true, but a root was provided to syncToRoot. This causes the function to run all the way till block: ${getDeploymentBlock(chainId)}, to discover all treeIds. Even if a tree is already found with that root and synced, it will keep going to find another tree with that root. If you intent on only syncing one tree (or known list of ids), please provide them and set autoDiscovery=false.`)
310
+ }
311
+ if (defaultToAutoDiscover) {
312
+ console.warn(`No treeIds provided for event sync, defaulting to autoDiscovery of all treeIds. This will run all the way till block: ${getDeploymentBlock(chainId)}, to discover all treeIds. If this intended you can safely ignore this warning or silence it by setting autoDiscovery=true. If you intent on only syncing one or a set of treeIds, please provide them to speed up syncing.`)
313
+ }
314
+
315
+ // sync backwards, sync every NewLeaf, UpdatedLeaf, RepeatedLeafs, event
316
+ const newestBlock = blockNumber ?? (await this.publicClient.getBlock({ blockTag: 'safe' })).number
317
+ // in discovery we go all the way down, if treeIds are provided, we go down till all them are synced
318
+ // if all of them are synced before, we don't sync all leaves, just those who happened after the most out of date tree was synced at
319
+ // when looking for a specific root we need to also go down to the lowest block since the root might be older then the last sync
320
+ const oldestBlock = discoverAllIds || syncToRoot !== undefined ? BigInt(getDeploymentBlock(chainId)) : this.getOldestSyncBlock(treeIds)
321
+
322
+ const syncState: SyncState = { treeCache: this.trees, treeState: {}, lastBlockSynced: newestBlock, unsyncedIds: new Set(treeIds.map((id) => toHex(id))) }
323
+
324
+ const insertEvents = hasRepeatedLeafs ? ["NewLeaf", "RepeatedLeafs"] as const : ["NewLeaf"] as const;
325
+ const leafEvents = insertOnlyTree ? insertEvents : ["UpdatedLeaf", ...insertEvents] as const
326
+ do {
327
+ const foundAllRoots = syncState.unsyncedIds.size === 0 && syncToRoot === undefined && discoverAllIds == false
328
+ await queryMultiEventsInChunks({
329
+ publicClient: this.publicClient,
330
+ contract: await this.getContract(),
331
+ eventNames: foundAllRoots ? leafEvents : ["NewRoot", ...leafEvents],
332
+ // only our treeIds. treeId is the 1st indexed param of NewRoot, an array means "any of these"
333
+ sharedEventFilterArgs: discoverAllIds ? undefined : { treeId: [...syncState.unsyncedIds].map((id) => BigInt(id)) },
334
+ firstBlock: oldestBlock,
335
+ lastBlock: syncState.lastBlockSynced,
336
+ reverseOrder: true,
337
+ maxEvents: Infinity,
338
+ chunkSize: chunkSize,
339
+ // if we need all treeIds?
340
+ postQueryFilter: getEventFilter(oldestBlock, syncState, { hashFunc: this.hashFunc, attemptFastSizeMatch: attemptFastSizeMatch, autoDiscover: discoverAllIds, syncToRoot: syncToRoot })
341
+ })
342
+ } while (syncState.unsyncedIds.size > 0 && syncState.lastBlockSynced > oldestBlock)
343
+
344
+ // cache the tree, or update the new/updated leafs of an existing cache tree.
345
+ const syncedTrees: { [treeId: Hex]: CachedTree } = {}
346
+ const allTreeIds = new Set([...treeIds.map((id) => toHex(id)), ...Object.keys(syncState.treeState) as Hex[]])
347
+ for (const treeId of allTreeIds) {
348
+ const newSyncState = syncState.treeState[treeId]
349
+ const cacheTree = this.trees[treeId];
350
+
351
+ const isNotOld = cacheTree === undefined
352
+ || (newSyncState !== undefined && newSyncState.lastSynced >= cacheTree.lastSynced)
353
+
354
+ // no-op, no new state and no rollback (syncToRoot on old root can be used to go back to a older root)
355
+ if (isNotOld === false && syncToRoot === undefined) {
356
+ syncedTrees[treeId] = cacheTree
357
+ continue
358
+ }
359
+
360
+ const canMerge = newSyncState !== undefined && cacheTree !== undefined && isNotOld
361
+ && BigInt(cacheTree.tree.size) <= newSyncState.targetSize
362
+
363
+
364
+ if (newSyncState !== undefined && newSyncState.tree === undefined) {
365
+ const targetSize = Number(newSyncState.targetSize)
366
+ const missing: number[] = []
367
+ for (let index = canMerge ? cacheTree.tree.size : 0; index < targetSize; index++) {
368
+ if (newSyncState.leaves[index] === undefined) {
369
+ missing.push(index)
370
+ break
371
+ }
372
+ }
373
+ if (missing.length > 0) {
374
+ throw new Error(
375
+ `Incomplete sync of treeId:${treeId}. Root ${toHex(newSyncState.expectedRoot)} has ${targetSize} leaves, ` +
376
+ `but no event was found indexes ${missing}. Possible causes: hasRepeatedLeafs:false, ` +
377
+ `insertOnlyTree:true, a scan that did not reach far enough back (it started at block ${oldestBlock}), ` +
378
+ `or a contract that does not emit an event for every leaf it stores.`
379
+ )
380
+ }
381
+ }
382
+
383
+ let synced: CachedTree
384
+ if (newSyncState?.tree !== undefined) {
385
+ // attemptFastSizeMatch was success full by trimming of leaves of the pre-existing cache tree.
386
+ // that caused a tree to already be built and checked so we can just use that rn.
387
+ // it's also almost always a rollback so we should not store in cache
388
+ // (almost always rollback, it could be that a reset happened, and just happens to have the same leaves as the previous tree but smaller size)
389
+ synced = {
390
+ tree: newSyncState.tree,
391
+ type: cacheTree!.type,
392
+ lastSynced: newSyncState.lastSynced,
393
+ insertOnlyTree: cacheTree!.insertOnlyTree
394
+ }
395
+ } else if (canMerge) {
396
+ // @notice mutates the cached tree in place, so `synced` *is* the cache entry and the
397
+ // isNotOld check below can no longer keep it out. See canMerge above.
398
+ const updatedIndexes: number[] = [];
399
+ const updatedLeaves: bigint[] = [];
400
+ const newLeafs: bigint[] = [];
401
+ newSyncState.leaves.forEach((leaf, index) => {
402
+ if (index < cacheTree.tree.size) {
403
+ updatedLeaves.push(leaf)
404
+ updatedIndexes.push(index)
405
+ } else {
406
+ newLeafs.push(leaf)
407
+ }
408
+ })
409
+ cacheTree.tree.updateMany(updatedIndexes, updatedLeaves)
410
+ if (newLeafs.length > 0) {
411
+ cacheTree.tree.insertMany(newLeafs)
412
+ }
413
+
414
+ cacheTree.lastSynced = newSyncState.lastSynced
415
+ synced = cacheTree
416
+ } else {
417
+ // cached tree does not exist or synced tree is smaller and newSyncState.leaves is complete
418
+ synced = {
419
+ tree: new LeanIMT(this.hashFunc, newSyncState ? newSyncState.leaves : []),
420
+ type: cacheTree ? cacheTree.type : await identifyTree(BigInt(treeId), this.contract),
421
+ lastSynced: newSyncState?.lastSynced ? newSyncState.lastSynced : 0n,
422
+ // what the caller claims about this sync, falling back to whatever was known before
423
+ insertOnlyTree: insertOnlyTree ?? cacheTree?.insertOnlyTree
424
+ }
425
+ }
426
+
427
+ if (newSyncState !== undefined && (synced.tree.root ?? 0n) !== newSyncState.expectedRoot) {
428
+ throw new Error(`syncing failed, expected root:${toHex(newSyncState.expectedRoot)} but got ${toHex(synced.tree.root ?? 0n)}`)
429
+ }
430
+
431
+ // a sync may only ever move the cache forward. A syncToRoot rollback is handed to the
432
+ // caller, but the cache keeps the newer tree it already had.
433
+ if (isNotOld) {
434
+ this.trees[treeId] = synced
435
+ }
436
+ syncedTrees[treeId] = synced;
437
+ }
438
+
439
+ if (syncToRoot !== undefined) {
440
+ const allTreeIds = Object.keys(syncedTrees) as Hex[]
441
+ const treeIdsWithNoRoot = allTreeIds.filter((id) => syncedTrees[id].tree.root !== syncToRoot)
442
+ // not one tree came back with this root. Also covers "no tree came back at all", which is
443
+ // what a syncToRoot with no treeIds does: it never auto discovers, so it filters on an
444
+ // empty set of ids and matches nothing.
445
+ if (allTreeIds.length === treeIdsWithNoRoot.length) {
446
+ throw new Error(`Root: ${syncToRoot} was never found for ${allTreeIds.length === 0
447
+ ? `any tree, none were synced at all. syncToRoot does not auto discover treeIds, so pass them in or set autoDiscovery=true`
448
+ : `any of the treeIds: ${allTreeIds}`}`)
449
+ }
450
+ if (treeIdsWithNoRoot.length > 0) {
451
+ const foundTreeIds = allTreeIds.filter((id) => false === treeIdsWithNoRoot.includes(id));
452
+ console.warn(
453
+ `Root: ${syncToRoot} was never found for some treeIds: ${treeIdsWithNoRoot}. But was found for ${foundTreeIds})`
454
+ )
455
+ treeIdsWithNoRoot.forEach((id) => delete syncedTrees[id])
456
+ }
457
+ }
458
+ return syncedTrees;
459
+ }
460
+
461
+ async getContract(): Promise<ReadableContract> {
462
+ if (this.contract) {
463
+ return this.contract
464
+ } else {
465
+ //[skinnyStorage, skinnyEvent, fatStorage, fatEvent]
466
+ const interfaceSupports = await Promise.all([
467
+ ERC165_IDS.skinnyStorage, ERC165_IDS.skinnyEvent, ERC165_IDS.fatStorage, ERC165_IDS.fatEvent
468
+ ].map((id) => supportsInterface(this.publicClient, this.contractAddress, id)))
469
+ const abis = [skinnyStorageAbi, skinnyEventAbi, fatStorageAbi, fatEventAbi]
470
+
471
+ const unionAbi = abis.filter((abi, i) => interfaceSupports[i]).flat()
472
+ this.contract = getContract({
473
+ address: this.contractAddress,
474
+ client: this.publicClient,
475
+ abi: [...unionAbi, ...iimtEventsAbi],
476
+ // yeah no amount of type juggling will be able to deal with these crazy union types.
477
+ }) as unknown as ReadableContract
478
+ return this.contract
479
+ }
480
+ }
481
+
482
+ getOldestSyncTreeId(treeIds: Hex[]): Hex {
483
+ let oldest = treeIds[0];
484
+ let oldestBlock = 2n ** 256n; // Infinity replacement
485
+ for (const id of treeIds) {
486
+ const tree = this.trees[id]
487
+ if (tree === undefined) {
488
+ return id
489
+ }
490
+ if (tree.lastSynced < oldestBlock) {
491
+ oldestBlock = tree.lastSynced
492
+ oldest = id
493
+ }
494
+ }
495
+ return oldest
496
+ }
497
+
498
+ getOldestSyncBlock(treeIds: bigint[]) {
499
+ const oldestTree = this.trees[this.getOldestSyncTreeId(treeIds.map((id) => toHex(id)))]
500
+ return oldestTree ? oldestTree.lastSynced : 0n
501
+ }
502
+
503
+ async getTreeType(treeId: Hex, chainId: number) {
504
+ return (await this.initTree(treeId, chainId)).type
505
+ }
506
+
507
+ async getLeavesStorage(treeId: Hex, chainId: number, startIndex: bigint, endIndex: bigint, blockNumber: bigint) {
508
+ const size = await this.getTreeSizeStorage(treeId, chainId, blockNumber)
509
+ if (startIndex > endIndex || endIndex > size) {
510
+ throw new Error(`Leaf range [${startIndex}, ${endIndex}) is out of range for treeId:${treeId}, which holds ${size} leaves at block:${blockNumber}.`)
511
+ }
512
+ const treeType = (await this.initTree(treeId, chainId)).type
513
+ const contract = await this.getContract()
514
+ switch (treeType) {
515
+ case TREE_TYPE.SKINNY_STORAGE:
516
+ // TODO debug_storageRange at for these 2 but not FAT_EVENT
517
+ return await (contract as any as ReadableContractSkinnyStorage).read.getSkinnyLeaves([BigInt(treeId), startIndex, endIndex], { blockNumber: blockNumber })
518
+ break;
519
+ case TREE_TYPE.FAT_STORAGE:
520
+ return await (contract as any as ReadableContractFatStorage).read.getFatLeaves([BigInt(treeId), startIndex, endIndex], { blockNumber: blockNumber })
521
+ break;
522
+ case TREE_TYPE.FAT_EVENT:
523
+ return await (contract as any as ReadableContractFatEvent).read.getFatLeaves([BigInt(treeId), startIndex, endIndex], { blockNumber: blockNumber })
524
+ break;
525
+ default:
526
+ throw new Error(`treeId ${treeId} has type: ${TREE_TYPE[treeType]} which is not supported`)
527
+ break;
528
+ }
529
+ }
530
+
531
+ async getTreeSizeStorage(treeId: Hex, chainId: number, blockNumber: bigint) {
532
+ const cachedSize = this.sizes[treeId]
533
+ if (cachedSize !== undefined && cachedSize.blockNumber === blockNumber) {
534
+ return cachedSize.size
535
+ }
536
+ const treeType = (await this.initTree(treeId, chainId)).type
537
+ const contract = await this.getContract()
538
+ let size: bigint
539
+ switch (treeType) {
540
+ case TREE_TYPE.SKINNY_STORAGE:
541
+ case TREE_TYPE.SKINNY_EVENT:
542
+ size = await (contract as any as ReadableContractSkinnyEvent).read.getSkinnySize([BigInt(treeId)], { blockNumber: blockNumber })
543
+ break;
544
+ case TREE_TYPE.FAT_STORAGE:
545
+ case TREE_TYPE.FAT_EVENT:
546
+ size = await (contract as any as ReadableContractFatEvent).read.getFatSize([BigInt(treeId)], { blockNumber: blockNumber })
547
+ break;
548
+ default:
549
+ throw new Error(`treeId ${treeId} has type: ${TREE_TYPE[treeType]} which is not supported`)
550
+ }
551
+ this.sizes[treeId] = { blockNumber: blockNumber, size: size }
552
+ return size
553
+ }
554
+
555
+ async getRootStorage(treeId: Hex, chainId: number, blockNumber: bigint) {
556
+ const treeType = (await this.initTree(treeId, chainId)).type
557
+ const contract = await this.getContract()
558
+ switch (treeType) {
559
+ case TREE_TYPE.SKINNY_STORAGE:
560
+ case TREE_TYPE.SKINNY_EVENT:
561
+ return await (contract as any as ReadableContractSkinnyEvent).read.getSkinnyRoot([BigInt(treeId)], { blockNumber: blockNumber })
562
+ case TREE_TYPE.FAT_STORAGE:
563
+ case TREE_TYPE.FAT_EVENT:
564
+ return await (contract as any as ReadableContractFatEvent).read.getFatRoot([BigInt(treeId)], { blockNumber: blockNumber })
565
+ default:
566
+ throw new Error(`treeId ${treeId} has type: ${TREE_TYPE[treeType]} which is not supported`)
567
+ }
568
+ }
569
+
570
+ async initTree(treeId: Hex, chainId: number) {
571
+ if (this.trees[treeId]) {
572
+ return this.trees[treeId]
573
+ }
574
+ const treeType = await identifyTree(BigInt(treeId), await this.getContract());
575
+ this.trees[treeId] = {
576
+ tree: new LeanIMT(this.hashFunc),
577
+ type: treeType,
578
+ lastSynced: BigInt(getDeploymentBlock(chainId))
579
+ }
580
+ return this.trees[treeId]
581
+ }
582
+ }
583
+
584
+ export async function identifyTree(
585
+ treeId: bigint,
586
+ contract: AnyContract,
587
+ ) {
588
+ // TODO we can dish out these calls concurrently. but storage always > event, remember storage inherits event!!
589
+ let skinnyStorageProm, skinnyEventProm, fatStorageProm, fatEventProm
590
+ const differentiatingFunctions = ["getSkinnyLeavesBaseSlot", "getSkinnySize", "getFatLeavesBaseSlot", "getFatSize"]
591
+ const contractFunctions = contract.abi.filter((abi) => abi.type === "function" && "name" in abi && differentiatingFunctions.includes(abi.name)).map((abi) => (abi as any).name)
592
+ if (contractFunctions.length === 0) {
593
+ // is okay we can still look for events! SkinnyEvent interface is not even use full while syncing, since we
594
+ // need events to get the leaves anyway.
595
+ // TODO we cant deal with NO_INTERFACE case rn because storage emits both repeatedLeaves and newLeaf which is double
596
+ // prob not really a problem?
597
+ return TREE_TYPE.NO_INTERFACE;
598
+ }
599
+ if (contractFunctions.includes("getSkinnyLeavesBaseSlot")) {
600
+ skinnyStorageProm = contract.read.getSkinnyLeavesBaseSlot([treeId])
601
+ }
602
+ if (contractFunctions.includes("getSkinnySize")) {
603
+ skinnyEventProm = (contract as unknown as ReadableContractSkinnyEvent).read.getSkinnySize([treeId])
604
+ }
605
+ if (contractFunctions.includes("getFatLeavesBaseSlot")) {
606
+ fatStorageProm = (contract as unknown as ReadableContractFatStorage).read.getFatLeavesBaseSlot([treeId])
607
+ }
608
+ if (contractFunctions.includes("getFatSize")) {
609
+ fatEventProm = (contract as unknown as ReadableContractFatEvent).read.getFatSize([treeId])
610
+ }
611
+
612
+
613
+ // @notice storage needs to come first in the arr, since both storage and event resolve if a tree type == STORAGE
614
+ const proms = [skinnyStorageProm, skinnyEventProm, fatStorageProm, fatEventProm]
615
+ const settled = await Promise.allSettled(proms);
616
+ const types = [TREE_TYPE.SKINNY_STORAGE, TREE_TYPE.SKINNY_EVENT, TREE_TYPE.FAT_STORAGE, TREE_TYPE.FAT_EVENT]
617
+ for (let index = 0; index < settled.length; index++) {
618
+ // proms[index] guard: undefined entries (interface absent) become fulfilled in allSettled
619
+ if (proms[index] !== undefined && settled[index].status == "fulfilled") {
620
+ return types[index]
621
+ }
622
+ }
623
+
624
+ // usually un-initialized or does not exist
625
+ return TREE_TYPE.UNKNOWN
626
+ }
627
+
628
+ type SyncState = {
629
+ lastBlockSynced: bigint, unsyncedIds: Set<Hex>,
630
+ treeCache: { [treeId: Hex]: CachedTree },
631
+ treeState: { [treeId: Hex]: { expectedRoot: bigint, tree?: LeanIMT<bigint>, leaves: bigint[], count: bigint, targetSize: bigint, lastSynced: bigint } }
632
+ }
633
+
634
+ type IMTEventFilter = PostQueryEventFilter<ReadableEvent<"NewRoot" | "NewLeaf" | "UpdatedLeaf" | "RepeatedLeafs">>
635
+ export function getEventFilter(
636
+ firstBlock: bigint, syncState: SyncState,
637
+ { autoDiscover, syncToRoot, attemptFastSizeMatch, hashFunc = LeanIMTHashFuncPoseidon2 }: { hashFunc?: LeanIMTHashFunction, attemptFastSizeMatch?: boolean, autoDiscover?: boolean, syncToRoot?: bigint } = {}
638
+ ) {
639
+ const quitEarly: IMTEventFilter = (allEvents, chunkEvents, chunkStart, chunkEnd) => {
640
+ let quit = false;
641
+ for (const event of chunkEvents.toReversed()) {
642
+ const treeId = toHex(event.args.treeId);
643
+ // have we found our NewRoot event yet?
644
+ if (syncState.treeState[treeId] === undefined) {
645
+ if (event.eventName === "NewRoot") {
646
+ if (syncToRoot === undefined || syncToRoot === event.args.root) {
647
+ //TODO in case that syncToRoot is provided, check the tree in cache, if size >= as root says we need.
648
+ // trim of excess leaves and check if root is already correct.
649
+ // this allows us to quit very early on insert only trees or ones with update if we are lucky no update happened
650
+ const simpleTrim = attemptFastSizeMatch && syncState.treeCache[treeId]?.tree.size > event.args.size
651
+ if (simpleTrim) {
652
+ const trimmed = syncState.treeCache[treeId]?.tree.leaves.slice(0, Number(event.args.size))
653
+ const startTime = performance.now();
654
+ const trimmedTree = new LeanIMT(hashFunc, trimmed);
655
+ const endTime = performance.now();
656
+ if (event.args.root === trimmedTree.root) {
657
+ console.log(`success full trim on treeId: ${treeId}`)
658
+ syncState.treeState[treeId] = {
659
+ leaves: [],
660
+ count: 0n,
661
+ targetSize: event.args.size,
662
+ lastSynced: event.blockNumber - 1n, // 2 roots can be in one block, so -1 to be safe
663
+ tree: trimmedTree,
664
+ expectedRoot: event.args.root
665
+ }
666
+ syncState.unsyncedIds.delete(treeId);
667
+ quit = true;
668
+ continue
669
+ } else {
670
+ console.warn(`failed trim, wasted ${endTime - startTime}ms on tree build`)
671
+ // console.warn(`Rolling back to root: ${toHex(event.args.root)} failed. Likely because a tree reset or update happened. If that is true, you can safely ignore this warning. TODO optimize this`)
672
+ //TODO: the entire tree was rebuilt here, just to fail. For a future re-write of a more scalable LeanIMTjs, make it so it can trim leaves, while re-using the internal nodes so rehashing is not needed for all.`)
673
+ }
674
+ }
675
+ syncState.treeState[treeId] = {
676
+ leaves: [],
677
+ count: 0n,
678
+ targetSize: event.args.size,
679
+ lastSynced: event.blockNumber - 1n, // 2 roots can be in one block, so -1 to be safe
680
+ tree: undefined,
681
+ expectedRoot: event.args.root
682
+ }
683
+ }
684
+ }
685
+ // we have found a NewRoot, or our specific new root if syncToRoot was set
686
+ // so syncState[treeId] contains something now
687
+ } else {
688
+ const tree = syncState.treeState[treeId]
689
+ if (tree.count === tree.targetSize || syncState.treeState[treeId].tree) {
690
+ syncState.unsyncedIds.delete(treeId);
691
+ quit = true;
692
+ continue
693
+ }
694
+ if (event.eventName === "RepeatedLeafs") {
695
+ const start = Number(event.args.startIndex) // startIndex == inclusive, index = start is correct
696
+ const end = Number(event.args.nextIndex) // nextIndex == exclusive, so index < end is correct here
697
+ for (let index = start; index < end; index++) {
698
+ if (tree.leaves[index] === undefined) {
699
+ tree.leaves[index] = event.args.leaf;
700
+ tree.count++;
701
+ }
702
+ }
703
+ } else if (event.eventName !== "NewRoot") {
704
+ const leafIndex = Number(event.args.index)
705
+ if (tree.leaves[leafIndex] === undefined) {
706
+ if (event.eventName === "NewLeaf") {
707
+ // @TODO this is not super scalable, but realistically on L1 you wont really hit numbers high
708
+ // enough, besides LeanIMT-js uses normal js arrays indexed by numbers so that is the first one to fail
709
+ // also due to ram usage
710
+ tree.leaves[leafIndex] = event.args.leaf
711
+ tree.count++;
712
+ } else {
713
+ // if (event.eventName === "UpdatedLeaf")
714
+ tree.leaves[leafIndex] = event.args.newLeaf;
715
+ tree.count++;
716
+ }
717
+ }
718
+ }
719
+ }
720
+ }
721
+ // we process all events on every chunk right away, we don't need allEvents, so just discard all events to save memory
722
+ // [allEvents, quit]
723
+ // `startBlock === firstBlock` <= detects, "is last chunk", assumes scanning backwards
724
+ if (quit || chunkStart === firstBlock) {
725
+ syncState.lastBlockSynced = chunkStart
726
+ }
727
+
728
+ return [[], quit]
729
+ }
730
+ if (autoDiscover) {
731
+ const quitAtEnd: IMTEventFilter = (allEvents, chunkEvents, chunkStart, chunkEnd) => { quitEarly(allEvents, chunkEvents, chunkStart, chunkEnd); return [[], false] };
732
+ return quitAtEnd
733
+ } else {
734
+ return quitEarly
735
+ }
736
+ }
737
+
738
+