@gibs/bridge-indexer 1.13.0

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.
@@ -0,0 +1,711 @@
1
+ import { gql } from 'graphql-request';
2
+ import { sortBy } from 'lodash-es';
3
+ import { indexerClient } from './endpoint.js';
4
+ import { getStoreKey, getTokenMetadata, loadTokenMetadata as loadTokenMetadataFromCache, parseTokenKey, } from '@gibs/bridge-client/token-metadata';
5
+ /**
6
+ * Every request in this module goes through {@link indexerClient}, which is a
7
+ * FUNCTION rather than a held client on purpose: a consumer configures the
8
+ * address during startup, and a client captured at module-evaluation time would
9
+ * have frozen the default before they ever got the chance.
10
+ */
11
+ const client = () => indexerClient();
12
+ // The rate this transfer's own fee manager had in force at index time.
13
+ // deriveDeliveredAmount (tracked-transfer/deliveredAmount.ts) reads
14
+ // feeUpdate.fee to reconstruct a delivered figure for a record whose
15
+ // amountOut was never written, and until this join was added that read
16
+ // always saw undefined because nothing had asked the indexer for it. A
17
+ // single foreign-key lookup per row costs nothing measurable -- ten rows
18
+ // with and without this field both land under 0.8 second against the live
19
+ // indexer -- so it is worth carrying even while feeUpdateOrderId is null on
20
+ // every currently deployed record (see deliveredAmount.ts): the join starts
21
+ // paying for itself the moment the pending re-index backfills it, with no
22
+ // second interface deploy needed.
23
+ const fragment = gql `{
24
+ messageHash
25
+ messageId
26
+ type
27
+ orderId
28
+ blockHash
29
+ chainId
30
+ transactionHash
31
+ from
32
+ to
33
+ amountIn
34
+ amountOut
35
+ encodedData
36
+ logIndex
37
+ requiredSignatureOrderId
38
+ confirmedSignatures
39
+ finishedSigning
40
+ originationChainId
41
+ originationAmbAddress
42
+ destinationChainId
43
+ destinationAmbAddress
44
+ originationOmnibridgeAddress
45
+ destinationOmnibridgeAddress
46
+ originationTokenAddress
47
+ destinationTokenAddress
48
+ handlingNative
49
+ deliveringNative
50
+ signatures
51
+ finishedSigning
52
+ delivered
53
+ feeUpdate {
54
+ fee
55
+ feeType
56
+ }
57
+ block {
58
+ chainId
59
+ hash
60
+ number
61
+ timestamp
62
+ baseFeePerGas
63
+ }
64
+ transaction {
65
+ chainId
66
+ hash
67
+ blockHash
68
+ index
69
+ from
70
+ to
71
+ value
72
+ gas
73
+ gasPrice
74
+ nonce
75
+ type
76
+ }
77
+ originationAMBBridge {
78
+ chainId
79
+ address
80
+ provider
81
+ side
82
+ }
83
+ destinationAMBBridge {
84
+ chainId
85
+ address
86
+ provider
87
+ side
88
+ }
89
+ requiredSignatures {
90
+ orderId
91
+ chainId
92
+ value
93
+ transactionHash
94
+ logIndex
95
+ }
96
+ completion {
97
+ messageHash
98
+ orderId
99
+ chainId
100
+ transactionHash
101
+ # WHEN the row landed, which is a different fact on each direction and is
102
+ # wanted on both. Going into the home chain this block is the release;
103
+ # going out of it the indexer writes the row on the ORIGIN chain when the
104
+ # validators finish, so the same timestamp is when the signature round
105
+ # closed. historyTransferView.ts reads the chain to decide which step
106
+ # the time belongs to.
107
+ block {
108
+ timestamp
109
+ }
110
+ }
111
+ delivery {
112
+ messageHash
113
+ orderId
114
+ chainId
115
+ transactionHash
116
+ deliverer
117
+ logIndex
118
+ # When the delivery landed on the destination chain.
119
+ block {
120
+ timestamp
121
+ }
122
+ }
123
+ originationToken {
124
+ address
125
+ chainId
126
+ ambAddress
127
+ originationAddress
128
+ originationChainId
129
+ destinationAddress
130
+ destinationChainId
131
+ }
132
+ destinationToken {
133
+ address
134
+ chainId
135
+ ambAddress
136
+ originationAddress
137
+ destinationAddress
138
+ }
139
+ feeDirector {
140
+ messageHash
141
+ recipient
142
+ settings
143
+ limit
144
+ multiplier
145
+ feeType
146
+ unwrapped
147
+ }
148
+ }`;
149
+ // GraphQL fragments for bridge data
150
+ const BRIDGE_CORE_FRAGMENT = gql `
151
+ fragment BridgeCore on UserRequest ${fragment}
152
+ `;
153
+ const PAGE_INFO_FRAGMENT = gql `
154
+ totalCount
155
+ pageInfo {
156
+ hasNextPage
157
+ hasPreviousPage
158
+ startCursor
159
+ endCursor
160
+ }`;
161
+ /**
162
+ * How many fee updates one archive query folds in. The table holds ten rows
163
+ * today, and the whole of it costs about 0.12 second of the query's 0.4 second —
164
+ * measured against the production indexer, with and without the field.
165
+ *
166
+ * IT IS A CAP, SO REACHING IT IS REPORTED. A limit that silently truncates hands
167
+ * the amount-out estimate a fee table that is quietly short.
168
+ */
169
+ const FEE_UPDATES_LIMIT = 1000;
170
+ // Query to get bridge transactions and fee data (optionally filtered by user account)
171
+ const GET_BRIDGES_QUERY = gql `
172
+ query GetBridges($where: UserRequestFilter, $limit: Int = 10, $after: String, $before: String) {
173
+ userRequests(where: $where, limit: $limit, after: $after, before: $before, orderBy: "orderId", orderDirection: "desc") {
174
+ items {
175
+ ...BridgeCore
176
+ }
177
+ ${PAGE_INFO_FRAGMENT}
178
+ }
179
+ latestFeeUpdates(limit: ${FEE_UPDATES_LIMIT}) {
180
+ items {
181
+ tokenAddress
182
+ feeManagerContract {
183
+ chainId
184
+ address
185
+ omnibridgeAddress
186
+ }
187
+ feeUpdate {
188
+ feeType
189
+ fee
190
+ }
191
+ }
192
+ }
193
+ }
194
+ ${BRIDGE_CORE_FRAGMENT}
195
+ `;
196
+ /**
197
+ * Hard cap on how many "released for someone else" message hashes are folded into a
198
+ * single history query. A normal account releases a handful; this bounds the OR-list
199
+ * for the pathological case. When an account exceeds it, the oldest deliveries past
200
+ * the cap are omitted from history (and the omission is logged).
201
+ */
202
+ const DELIVERED_HASHES_LIMIT = 500;
203
+ // Resolves the message hashes of bridges an account RELEASED for others (the on-chain
204
+ // `deliverer`). The deliverer lives only on the Delivery table, so this feeds
205
+ // buildBridgeFilter to widen history beyond the from/to scope.
206
+ const GET_DELIVERIES_BY_DELIVERER_QUERY = gql `
207
+ query GetDeliveriesByDeliverer($deliverer: String!, $limit: Int = 500) {
208
+ deliverys(
209
+ where: { deliverer: $deliverer }
210
+ limit: $limit
211
+ orderBy: "orderId"
212
+ orderDirection: "desc"
213
+ ) {
214
+ items {
215
+ messageHash
216
+ }
217
+ totalCount
218
+ }
219
+ }
220
+ `;
221
+ /**
222
+ * Loads token metadata for unique token/chain combinations found in a set of bridges.
223
+ * Exported for independent testability.
224
+ */
225
+ export async function loadTokenMetadataForBridges(bridges) {
226
+ const tokenMetadata = new Map();
227
+ // Extract unique token/chain combinations
228
+ const uniqueTokens = [];
229
+ const uniqueTokenKeys = new Set();
230
+ bridges.forEach((bridge) => {
231
+ // Try to use token relations first, fallback to individual fields
232
+ // Add origination token
233
+ let originationAddress = null;
234
+ let originationChainId = null;
235
+ if (bridge.originationToken?.address && bridge.originationToken?.chainId) {
236
+ originationAddress = bridge.originationToken.address;
237
+ originationChainId = Number(bridge.originationToken.chainId);
238
+ }
239
+ else if (bridge.originationTokenAddress && bridge.originationChainId) {
240
+ originationAddress = bridge.originationTokenAddress;
241
+ originationChainId = Number(bridge.originationChainId);
242
+ }
243
+ if (originationAddress && originationChainId) {
244
+ const key = getStoreKey(originationChainId, originationAddress);
245
+ if (!uniqueTokenKeys.has(key)) {
246
+ uniqueTokenKeys.add(key);
247
+ uniqueTokens.push({ chainId: originationChainId, address: originationAddress });
248
+ if (originationChainId === 56) {
249
+ // BSC chain ID
250
+ console.log(`Found BSC origination token:`, {
251
+ address: originationAddress,
252
+ chainId: originationChainId,
253
+ key,
254
+ });
255
+ }
256
+ }
257
+ }
258
+ // Add destination token if it exists and is different
259
+ let destinationAddress = null;
260
+ let destinationChainId = null;
261
+ if (bridge.destinationToken?.address && bridge.destinationToken?.chainId) {
262
+ destinationAddress = bridge.destinationToken.address;
263
+ destinationChainId = Number(bridge.destinationToken.chainId);
264
+ }
265
+ else if (bridge.destinationTokenAddress && bridge.destinationChainId) {
266
+ destinationAddress = bridge.destinationTokenAddress;
267
+ destinationChainId = Number(bridge.destinationChainId);
268
+ }
269
+ if (destinationAddress && destinationChainId && destinationAddress !== originationAddress) {
270
+ const key = getStoreKey(destinationChainId, destinationAddress);
271
+ if (!uniqueTokenKeys.has(key)) {
272
+ uniqueTokenKeys.add(key);
273
+ uniqueTokens.push({ chainId: destinationChainId, address: destinationAddress });
274
+ // if (destinationChainId === 56) { // BSC chain ID
275
+ // console.log(`Found BSC destination token:`, { address: destinationAddress, chainId: destinationChainId, key })
276
+ // }
277
+ }
278
+ }
279
+ });
280
+ // Ask the RPC-backed loader only for tokens the in-memory store does not have
281
+ // yet. A token's symbol/name/decimals never change once fetched, so a token
282
+ // already in the store is a cache HIT, not a reason to re-run its multicall.
283
+ //
284
+ // Before this filter, every unique token referenced by the current page was
285
+ // sent through unconditionally on every call — and this function runs on the
286
+ // initial load, on each 15-second background poll, and on every page turn or
287
+ // filter change. A page whose tokens never change still repeated the same
288
+ // multicall roughly four times a minute for as long as History stayed
289
+ // mounted. The alias this loader is imported under (`loadTokenMetadataFromCache`)
290
+ // already promised cache-first behaviour; the underlying function never
291
+ // checked the store, so the name was ahead of what it did.
292
+ const uncachedTokens = uniqueTokens.filter((token) => getTokenMetadata(token.chainId, token.address) === null);
293
+ if (uncachedTokens.length > 0) {
294
+ await loadTokenMetadataFromCache(uncachedTokens.map((token) => ({
295
+ chainId: token.chainId,
296
+ address: token.address,
297
+ })));
298
+ }
299
+ // Now get metadata from the populated cache
300
+ Array.from(uniqueTokenKeys).forEach((key) => {
301
+ try {
302
+ const { chainId, address } = parseTokenKey(key);
303
+ const metadata = getTokenMetadata(chainId, address);
304
+ if (metadata) {
305
+ tokenMetadata.set(key, metadata);
306
+ }
307
+ }
308
+ catch (error) {
309
+ console.error(`Failed to load metadata for ${key}:`, error);
310
+ }
311
+ });
312
+ return tokenMetadata;
313
+ }
314
+ // Cache for bridge transactions with 5-second invalidation
315
+ const bridgeCache = new Map();
316
+ const BRIDGE_CACHE_TTL = 5 * 1000; // 5 seconds
317
+ /**
318
+ * Builds the GraphQL filter for user request queries from bridge loading params.
319
+ * Pure function — no side effects, exported for independent testability.
320
+ */
321
+ export function buildBridgeFilter(params, deliveredMessageHashes = []) {
322
+ const { address, hash, filterMode = 'pending', hiddenChainIds = [] } = params;
323
+ let filter;
324
+ // A hash is a precise, globally-unique lookup. When present it overrides
325
+ // address scoping entirely — never AND-combine it with the from/to filter,
326
+ // or you could only resolve transactions owned by the connected account.
327
+ if (hash) {
328
+ const normalizedHash = hash.toLowerCase();
329
+ filter = {
330
+ OR: [
331
+ { transactionHash: normalizedHash },
332
+ { messageId: normalizedHash },
333
+ { messageHash: normalizedHash },
334
+ ],
335
+ };
336
+ }
337
+ if (!hash && address) {
338
+ // Scope to the connected account. Beyond bridges it SENT (`from`) or RECEIVED
339
+ // (`to`), include bridges it RELEASED on behalf of someone else — the on-chain
340
+ // `deliverer`. That party is recorded only on the separate Delivery table
341
+ // (UserRequestFilter has no `deliverer` field), so the caller resolves the
342
+ // delivered message hashes first and the OR is widened to match them here.
343
+ //
344
+ // Lowercase HERE rather than trusting callers. Ponder stores hex columns
345
+ // lowercased and does not normalize the query side, so a checksummed address
346
+ // matches nothing — silently, with no error. The one current caller already
347
+ // lowercases, but a funnel-level invariant that every future call site has to
348
+ // remember is the same defect waiting on the next deep link or cross-surface
349
+ // action. Compare `fetchDeliveredMessageHashes`, which normalizes internally.
350
+ const normalizedAddress = address.toLowerCase();
351
+ const scopes = [{ from: normalizedAddress }, { to: normalizedAddress }];
352
+ if (deliveredMessageHashes.length > 0) {
353
+ scopes.push({ messageHash_in: deliveredMessageHashes });
354
+ }
355
+ filter = { OR: scopes };
356
+ }
357
+ // The status toggle narrows a BROWSE. A hash lookup is not a browse — it is the
358
+ // user naming one transaction, and it already overrides address scoping three
359
+ // lines up for exactly that reason. Intersecting it with a delivered filter meant
360
+ // searching a transaction that had just completed returned nothing, because a
361
+ // toggle set earlier for an unrelated purpose was still on. Zero rows for a hash
362
+ // the indexer holds reads as "the bridge lost my transaction".
363
+ //
364
+ // `all` carries NO delivered filter — not a third value of one. Every request
365
+ // is either pending or completed, so asking for both means asking for neither
366
+ // half, and an `AND` clause naming one of them would silently halve the
367
+ // answer. The old three-state all/pending toggle was incoherent for a
368
+ // different reason: its pair was not a partition, so "all" and "pending"
369
+ // showed the same newest-first rows and the control read as broken.
370
+ if (!hash && filterMode !== 'all') {
371
+ const statusFilter = { AND: [{ delivered: filterMode === 'completed' }] };
372
+ filter = filter ? { AND: [filter, statusFilter] } : statusFilter;
373
+ }
374
+ // THE NETWORK FILTER NARROWS A BROWSE, AND A HASH LOOKUP IS NOT A BROWSE.
375
+ // Same reasoning as the status toggle two blocks up: the reader has named one
376
+ // transaction, and intersecting that with a filter they set earlier for an
377
+ // unrelated purpose returns nothing for a transaction the indexer holds. Zero
378
+ // rows for a hash reads as "the bridge lost my transfer".
379
+ //
380
+ // BOTH ENDS, BECAUSE A TRANSFER HAS TWO. Excluding only the origination chain
381
+ // would leave every crossing INTO a hidden network on screen — half a filter,
382
+ // which is worse than none because it looks like it worked. Both columns are
383
+ // `notNull` in the indexer's schema (`UserRequest.originationChainId`,
384
+ // `UserRequest.destinationChainId`), so `_not_in` cannot swallow a row on a
385
+ // null the way it would on a nullable column, where SQL's `NOT IN` yields
386
+ // unknown and drops the row.
387
+ //
388
+ // The identifiers are sent as strings. The column is a `bigint` and the schema
389
+ // types it `Scalars['BigInt']`, which this client serialises from a string;
390
+ // sending a JavaScript number would be a silent precision hazard the day a
391
+ // chain id passes the safe-integer range.
392
+ if (!hash && hiddenChainIds.length > 0) {
393
+ const excluded = hiddenChainIds.map((chainId) => String(chainId));
394
+ const networkFilter = {
395
+ AND: [
396
+ { originationChainId_not_in: excluded },
397
+ { destinationChainId_not_in: excluded },
398
+ ],
399
+ };
400
+ filter = filter ? { AND: [filter, networkFilter] } : networkFilter;
401
+ }
402
+ return filter;
403
+ }
404
+ /** Nothing released, and nothing left out. The answer for every account with no address. */
405
+ const NO_DELIVERED_HASHES = { hashes: [], omitted: 0 };
406
+ /**
407
+ * How long one account's resolved delivered-hash list is reused, in milliseconds.
408
+ *
409
+ * WHY A CACHE OF ITS OWN, AND WHY IT IS LONGER THAN THE PAGE CACHE. The list
410
+ * answers "which bridges did this account release for somebody else". That is a
411
+ * property of the ACCOUNT, and it changes only when the reader releases another
412
+ * one. It is not a property of the page — yet it was resolved again in front of
413
+ * EVERY archive page: on arrival, on each fifteen-second poll, on every page
414
+ * turn and on every status or search change. For a delivery account that is up
415
+ * to five hundred delivery records fetched to draw ten rows, four times a
416
+ * minute, plus a request body of thirty-seven kilobytes carrying every one of
417
+ * those hashes back to the indexer.
418
+ *
419
+ * Measured against the production indexer, the two busiest delivery accounts
420
+ * hold 8,598 and 13,561 releases; both return the full five hundred every time.
421
+ * That is the "loading hundreds of bridges to show ten" the reader can see in a
422
+ * network panel, and it is the reason this cache exists.
423
+ *
424
+ * A minute is the compromise. The reader's OWN release does not have to wait for
425
+ * it: {@link forgetDeliveredMessageHashes} drops the entry the moment their
426
+ * transaction lands, which is the only event that can make this list stale for
427
+ * the person looking at it.
428
+ */
429
+ const DELIVERED_HASHES_CACHE_TTL = 60 * 1000;
430
+ /** Resolved delivered-hash lists, keyed by lowercased address. */
431
+ const deliveredHashesCache = new Map();
432
+ /**
433
+ * Drops one account's cached delivered-hash list, so the next history fetch
434
+ * resolves it again.
435
+ *
436
+ * Called when the reader's own transaction is mined. A release the READER just
437
+ * made is the one change this list can undergo while they are watching it, and
438
+ * making them wait out the cache to see it would defeat the burst poll that
439
+ * exists for exactly that moment.
440
+ *
441
+ * @param address - the account whose list to forget. No address forgets nothing.
442
+ */
443
+ export const forgetDeliveredMessageHashes = (address) => {
444
+ if (!address)
445
+ return;
446
+ deliveredHashesCache.delete(address.toLowerCase());
447
+ };
448
+ /** Clears every cached delivered-hash list. Exported for test isolation. */
449
+ export const _clearDeliveredHashesCache = () => deliveredHashesCache.clear();
450
+ /**
451
+ * One account's delivered-hash list IF it is already resolved and still fresh,
452
+ * without starting a fetch for it.
453
+ *
454
+ * WHY LOOKING IS A DIFFERENT QUESTION FROM ASKING. `fetchBridgeTransactions`
455
+ * needs to know whether waiting for this list is free before it decides whether
456
+ * to run the archive query alongside it or after it. A cached list costs
457
+ * nothing to wait for, so the archive query can be built widened straight away;
458
+ * an unresolved one costs a whole round trip, and that is the round trip the
459
+ * page used to sit through before it asked for a single transfer.
460
+ *
461
+ * A STALE ENTRY READS AS ABSENT. `fetchDeliveredMessageHashes` sweeps expired
462
+ * entries on its way past, so an entry can outlive its window between sweeps.
463
+ * Returning one would hand the caller a list it believes is current.
464
+ *
465
+ * @param address - the account to look up. No address has no list.
466
+ * @returns the resolved list's promise, or null when there is no fresh one.
467
+ */
468
+ export const peekDeliveredMessageHashes = (address) => {
469
+ if (!address)
470
+ return null;
471
+ const entry = deliveredHashesCache.get(address.toLowerCase());
472
+ if (!entry)
473
+ return null;
474
+ if (entry.timestamp < Date.now() - DELIVERED_HASHES_CACHE_TTL)
475
+ return null;
476
+ return entry.result;
477
+ };
478
+ /**
479
+ * Fetches the message hashes of bridges this account RELEASED on behalf of others —
480
+ * the on-chain `deliverer` (the account that submitted the release transaction).
481
+ * Those bridges never match the history query's from/to scope, so without this they
482
+ * are invisible in the account's history.
483
+ *
484
+ * Ponder stores hex columns lowercase but does not normalize the query side, so the
485
+ * deliverer is queried lowercased to match (the same normalization the from/to scope
486
+ * relies on). Returns lowercased message hashes; nothing when no address.
487
+ *
488
+ * The answer is cached per account for {@link DELIVERED_HASHES_CACHE_TTL}. See
489
+ * that constant for why: this is the query that was reading hundreds of records
490
+ * in front of every ten-row page.
491
+ *
492
+ * Resilient by design: a failure here returns an empty list rather than throwing, so
493
+ * a deliveries-query problem degrades history to the from/to scope instead of blanking
494
+ * the whole panel. A failure is NEVER cached — a reader whose network blipped once
495
+ * would otherwise lose their released bridges for the full minute.
496
+ */
497
+ export async function fetchDeliveredMessageHashes(address) {
498
+ if (!address) {
499
+ return NO_DELIVERED_HASHES;
500
+ }
501
+ const deliverer = address.toLowerCase();
502
+ const now = Date.now();
503
+ for (const [key, entry] of deliveredHashesCache) {
504
+ if (entry.timestamp < now - DELIVERED_HASHES_CACHE_TTL) {
505
+ deliveredHashesCache.delete(key);
506
+ }
507
+ }
508
+ const cached = deliveredHashesCache.get(deliverer);
509
+ if (cached) {
510
+ return cached.result;
511
+ }
512
+ // Set by the catch below and read once the promise settles. A flag rather than
513
+ // a self-reference inside the async body, which would be in its own temporal
514
+ // dead zone if `client().request` ever threw synchronously.
515
+ let didFail = false;
516
+ const resultPromise = (async () => {
517
+ try {
518
+ const data = await client().request(GET_DELIVERIES_BY_DELIVERER_QUERY, { deliverer, limit: DELIVERED_HASHES_LIMIT });
519
+ const omitted = Math.max(data.deliverys.totalCount - DELIVERED_HASHES_LIMIT, 0);
520
+ if (omitted > 0) {
521
+ console.warn(`Deliverer ${deliverer} has more than ${DELIVERED_HASHES_LIMIT} deliveries; ` +
522
+ 'older bridges released for others may be missing from history');
523
+ }
524
+ return {
525
+ hashes: data.deliverys.items.map((item) => item.messageHash.toLowerCase()),
526
+ omitted,
527
+ };
528
+ }
529
+ catch (error) {
530
+ console.error('Failed to fetch delivered message hashes:', error);
531
+ didFail = true;
532
+ return NO_DELIVERED_HASHES;
533
+ }
534
+ })();
535
+ deliveredHashesCache.set(deliverer, { timestamp: now, result: resultPromise });
536
+ // NEVER LEAVE A FAILURE IN THE CACHE. The empty list a failure degrades to is a
537
+ // fallback, not an answer, and holding it for a full minute would hide a
538
+ // reader's released bridges long after the indexer came back. The entry is
539
+ // dropped only while it is still this one, so a later fetch that already
540
+ // replaced it survives.
541
+ resultPromise.then(() => {
542
+ if (!didFail)
543
+ return;
544
+ if (deliveredHashesCache.get(deliverer)?.result !== resultPromise)
545
+ return;
546
+ deliveredHashesCache.delete(deliverer);
547
+ });
548
+ return resultPromise;
549
+ }
550
+ /** Clears the in-memory bridge cache. Exported for test isolation. */
551
+ export const _clearBridgeCache = () => bridgeCache.clear();
552
+ /**
553
+ * Turns one archive response into the shape the panel reads.
554
+ *
555
+ * Extracted because two call sites in {@link fetchBridgeTransactions} now
556
+ * produce that response — one for an account whose release list was already
557
+ * known, one for an account whose release list was resolved alongside the page.
558
+ * Two copies of the sort, the token-metadata load and the fee warning is two
559
+ * places for the ordering to drift.
560
+ *
561
+ * @param data - the archive query's response.
562
+ * @param released - what this account released for other people, and what did not fit.
563
+ * @param controller - the caller's abort controller; an aborted fetch yields null.
564
+ * @returns the page, or null when the caller gave up while token metadata loaded.
565
+ */
566
+ async function toBridgeData(data, released, controller) {
567
+ if (controller.signal.aborted) {
568
+ return null;
569
+ }
570
+ const sortedUserRequests = sortBy(data.userRequests.items, [
571
+ (bridge) => -BigInt(bridge.orderId),
572
+ ]);
573
+ const tokenMetadata = await loadTokenMetadataForBridges(sortedUserRequests);
574
+ if (data.latestFeeUpdates.items.length >= FEE_UPDATES_LIMIT) {
575
+ console.warn('Latest fee updates limit reached, some fee updates may be missing');
576
+ }
577
+ return {
578
+ userRequests: sortedUserRequests,
579
+ tokenMetadata,
580
+ feeData: data.latestFeeUpdates.items,
581
+ pageInfo: data.userRequests.pageInfo,
582
+ totalCount: data.userRequests.totalCount,
583
+ releasedForOthersOmitted: released.omitted,
584
+ };
585
+ }
586
+ /**
587
+ * Inner fetching logic for loadBridgeTransactions.
588
+ * Exported so it can be tested independently of the Svelte loading store wrapper.
589
+ */
590
+ export async function fetchBridgeTransactions(rawParams, controller) {
591
+ const params = rawParams || {};
592
+ const { limit = 10, after, before, filterMode = 'pending', address, hash, hiddenChainIds = [], } = params;
593
+ // Key on the NORMALIZED address and hash, the same forms `buildBridgeFilter`
594
+ // queries with, so a checksummed and a lowercased spelling of one account share
595
+ // a single cache entry rather than issuing two identical round trips.
596
+ //
597
+ // The hidden networks are SORTED into the key for the same reason. They change
598
+ // which rows come back, so leaving them out would serve one network selection's
599
+ // page under another's — and two orderings of one selection would split a
600
+ // single answer across two entries and two round trips.
601
+ const cacheKey = JSON.stringify({
602
+ address: address?.toLowerCase() ?? null,
603
+ hash: hash?.toLowerCase() ?? null,
604
+ limit,
605
+ after,
606
+ before,
607
+ filterMode,
608
+ hiddenChainIds: [...hiddenChainIds].sort((first, second) => first - second),
609
+ });
610
+ // Clean up stale cache entries
611
+ const now = Date.now();
612
+ for (const [key, value] of bridgeCache) {
613
+ if (value.timestamp < now - BRIDGE_CACHE_TTL) {
614
+ bridgeCache.delete(key);
615
+ }
616
+ }
617
+ // Return from cache when still fresh
618
+ const cached = bridgeCache.get(cacheKey);
619
+ if (cached && cached.timestamp > now - BRIDGE_CACHE_TTL) {
620
+ const result = await cached.result;
621
+ if (result) {
622
+ console.log('returning cached bridge data', cached);
623
+ return cached.result;
624
+ }
625
+ }
626
+ // One page of the archive, for a given set of released-for-others hashes.
627
+ // Named so the two call sites below cannot drift in what they ask for.
628
+ const askArchive = (deliveredMessageHashes) => {
629
+ const variables = {
630
+ where: buildBridgeFilter(params, deliveredMessageHashes),
631
+ limit,
632
+ after,
633
+ before,
634
+ };
635
+ return client().request(GET_BRIDGES_QUERY, variables);
636
+ };
637
+ const dataPromise = (async () => {
638
+ try {
639
+ // Bridges this account released for others (the on-chain `deliverer`) aren't
640
+ // matched by the from/to scope, so those message hashes widen the filter.
641
+ // A hash lookup is a global, account-independent search — skip it then (no
642
+ // account scoping applies).
643
+ const releasesAreRelevant = !hash && Boolean(address);
644
+ // THE RELEASE LIST USED TO GATE THE PAGE, AND THAT COST A WHOLE ROUND TRIP
645
+ // OF WAITING BEFORE THE ARCHIVE WAS ASKED FOR ANYTHING.
646
+ //
647
+ // The two requests ran in series: resolve every bridge this account
648
+ // released for other people, THEN ask for ten transfers. Measured against
649
+ // the production indexer the same query answers in 0.2 seconds when it is
650
+ // warm and in 4 to 8 seconds when it is not, so two in series was the
651
+ // difference between half a second and a quarter of a minute — with the
652
+ // reader looking at a loading line for all of it.
653
+ //
654
+ // A CACHED LIST IS FREE TO WAIT FOR, so it is still waited for: the widened
655
+ // query goes out immediately and nothing speculative is issued. That is the
656
+ // path every background poll and every page turn takes, because the list is
657
+ // held per account for a minute.
658
+ const cachedReleases = releasesAreRelevant ? peekDeliveredMessageHashes(address) : null;
659
+ if (cachedReleases !== null) {
660
+ const released = await cachedReleases;
661
+ if (controller.signal.aborted) {
662
+ return null;
663
+ }
664
+ return await toBridgeData(await askArchive(released.hashes), released, controller);
665
+ }
666
+ // NOTHING CACHED, SO BOTH QUESTIONS GO OUT AT ONCE. The unwidened page is
667
+ // not a guess for most accounts — it is the FINAL answer, because an
668
+ // account that has released nothing for anybody produces exactly this
669
+ // filter. Only a delivery account pays for a second archive query here,
670
+ // and only once per minute, which is what the release cache is for.
671
+ const releaseRequest = releasesAreRelevant
672
+ ? fetchDeliveredMessageHashes(address)
673
+ : Promise.resolve(NO_DELIVERED_HASHES);
674
+ const unwidenedRequest = askArchive([]);
675
+ // A rejection nobody awaits is an unhandled rejection. This request is
676
+ // discarded whenever the release list turns out to be non-empty, so it
677
+ // needs a handler even though the `await` below still sees the real error
678
+ // on the path that does use it.
679
+ unwidenedRequest.catch(() => { });
680
+ const released = await releaseRequest;
681
+ if (controller.signal.aborted) {
682
+ return null;
683
+ }
684
+ const data = released.hashes.length === 0 ? await unwidenedRequest : await askArchive(released.hashes);
685
+ return await toBridgeData(data, released, controller);
686
+ }
687
+ catch (error) {
688
+ console.error('Failed to fetch bridge transactions:', error);
689
+ if (controller.signal.aborted) {
690
+ return null;
691
+ }
692
+ throw error;
693
+ }
694
+ })();
695
+ // Cache the promise to prevent duplicate in-flight requests
696
+ bridgeCache.set(cacheKey, { timestamp: now, result: dataPromise });
697
+ // Never serve a FAILURE from the cache. Without this, a rejected promise stayed
698
+ // cached for the full five seconds, so the error panel's Retry button — pressed
699
+ // immediately, which is the natural reaction — re-awaited the same rejection and
700
+ // reported the identical failure without touching the network, even after the
701
+ // indexer had recovered. TanStack Query's own backoff retries landed inside the
702
+ // same window. The chained `.catch` handles its own rejection, so this does not
703
+ // create an unhandled one; `dataPromise` is returned untouched and still rejects
704
+ // for the real consumer.
705
+ dataPromise.catch(() => {
706
+ if (bridgeCache.get(cacheKey)?.result === dataPromise) {
707
+ bridgeCache.delete(cacheKey);
708
+ }
709
+ });
710
+ return dataPromise;
711
+ }