@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.
- package/LICENSE +15 -0
- package/README.md +62 -0
- package/dist/bridge-status.d.ts +58 -0
- package/dist/bridge-status.js +23 -0
- package/dist/endpoint.d.ts +49 -0
- package/dist/endpoint.js +59 -0
- package/dist/graphql.d.ts +2537 -0
- package/dist/graphql.js +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +23 -0
- package/dist/live-status.d.ts +18 -0
- package/dist/live-status.js +178 -0
- package/dist/transfers.d.ts +147 -0
- package/dist/transfers.js +711 -0
- package/package.json +104 -0
- package/schema.graphql +2047 -0
|
@@ -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
|
+
}
|