tapimo 0.8.0 → 0.10.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.
Files changed (223) hide show
  1. package/dist/App.d.ts +7410 -7613
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/App.js +0 -1
  4. package/dist/App.js.map +1 -1
  5. package/dist/Webhooks.d.ts +21 -20
  6. package/dist/Webhooks.d.ts.map +1 -1
  7. package/dist/Webhooks.js +66 -23
  8. package/dist/Webhooks.js.map +1 -1
  9. package/dist/admin/apps/verified-tokens.d.ts +74 -74
  10. package/dist/apps/Zones.d.ts +34 -22
  11. package/dist/apps/Zones.d.ts.map +1 -1
  12. package/dist/apps/Zones.js +9 -8
  13. package/dist/apps/Zones.js.map +1 -1
  14. package/dist/apps/data/App.d.ts +3120 -2782
  15. package/dist/apps/data/App.d.ts.map +1 -1
  16. package/dist/apps/data/FxOracle.d.ts +4 -5
  17. package/dist/apps/data/FxOracle.d.ts.map +1 -1
  18. package/dist/apps/data/FxOracle.js +19 -4
  19. package/dist/apps/data/FxOracle.js.map +1 -1
  20. package/dist/apps/data/routes/activities.d.ts.map +1 -1
  21. package/dist/apps/data/routes/activities.js +32 -27
  22. package/dist/apps/data/routes/activities.js.map +1 -1
  23. package/dist/apps/data/routes/addresses.d.ts +283 -52
  24. package/dist/apps/data/routes/addresses.d.ts.map +1 -1
  25. package/dist/apps/data/routes/balances.d.ts +291 -21
  26. package/dist/apps/data/routes/balances.d.ts.map +1 -1
  27. package/dist/apps/data/routes/balances.js +232 -66
  28. package/dist/apps/data/routes/balances.js.map +1 -1
  29. package/dist/apps/data/routes/blocks.d.ts +4 -4
  30. package/dist/apps/data/routes/coingecko.d.ts +54 -54
  31. package/dist/apps/data/routes/earn.d.ts +642 -126
  32. package/dist/apps/data/routes/earn.d.ts.map +1 -1
  33. package/dist/apps/data/routes/earn.js +627 -58
  34. package/dist/apps/data/routes/earn.js.map +1 -1
  35. package/dist/apps/data/routes/exchanges.d.ts +120 -120
  36. package/dist/apps/data/routes/exchanges.d.ts.map +1 -1
  37. package/dist/apps/data/routes/exchanges.js +114 -118
  38. package/dist/apps/data/routes/exchanges.js.map +1 -1
  39. package/dist/apps/data/routes/receipts.d.ts.map +1 -1
  40. package/dist/apps/data/routes/receipts.js +68 -47
  41. package/dist/apps/data/routes/receipts.js.map +1 -1
  42. package/dist/apps/data/routes/tokens.d.ts +2 -0
  43. package/dist/apps/data/routes/tokens.d.ts.map +1 -1
  44. package/dist/apps/data/routes/transactions.d.ts +59 -51
  45. package/dist/apps/data/routes/transactions.d.ts.map +1 -1
  46. package/dist/apps/data/routes/transactions.js +25 -23
  47. package/dist/apps/data/routes/transactions.js.map +1 -1
  48. package/dist/apps/data/routes/transfers.d.ts +40 -18
  49. package/dist/apps/data/routes/transfers.d.ts.map +1 -1
  50. package/dist/apps/data/routes/transfers.js +618 -105
  51. package/dist/apps/data/routes/transfers.js.map +1 -1
  52. package/dist/apps/data/routes/valuation.d.ts +21 -21
  53. package/dist/apps/data/routes/valuation.d.ts.map +1 -1
  54. package/dist/apps/data/routes/valuation.js +2 -2
  55. package/dist/apps/data/routes/valuation.js.map +1 -1
  56. package/dist/apps/data/routes/verified-tokens.d.ts +37 -37
  57. package/dist/apps/data/routes/webhooks.d.ts +102 -505
  58. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  59. package/dist/apps/data/routes/webhooks.js +484 -342
  60. package/dist/apps/data/routes/webhooks.js.map +1 -1
  61. package/dist/apps/data/routes/zones.d.ts +12 -12
  62. package/dist/apps/data/routes/zones.d.ts.map +1 -1
  63. package/dist/apps/data/routes/zones.js +3 -1
  64. package/dist/apps/data/routes/zones.js.map +1 -1
  65. package/dist/apps/funding/App.d.ts +127 -386
  66. package/dist/apps/funding/App.d.ts.map +1 -1
  67. package/dist/apps/funding/App.js +20 -5
  68. package/dist/apps/funding/App.js.map +1 -1
  69. package/dist/apps/funding/routes/deposit-addresses.d.ts +141 -532
  70. package/dist/apps/funding/routes/deposit-addresses.d.ts.map +1 -1
  71. package/dist/apps/funding/routes/deposit-addresses.js +21 -103
  72. package/dist/apps/funding/routes/deposit-addresses.js.map +1 -1
  73. package/dist/apps/funding/routes/transfers.d.ts +4 -4
  74. package/dist/apps/funding/routes/transfers.js +5 -5
  75. package/dist/apps/funding/routes/transfers.js.map +1 -1
  76. package/dist/apps/management/App.d.ts +429 -727
  77. package/dist/apps/management/App.d.ts.map +1 -1
  78. package/dist/apps/management/routes/api-keys.d.ts +24 -24
  79. package/dist/apps/management/routes/billing.d.ts +72 -72
  80. package/dist/apps/management/routes/faucet.d.ts +6 -6
  81. package/dist/apps/management/routes/invite-links.d.ts +38 -38
  82. package/dist/apps/management/routes/me.d.ts +6 -6
  83. package/dist/apps/management/routes/members.d.ts +30 -30
  84. package/dist/apps/management/routes/projects.d.ts +20 -20
  85. package/dist/apps/management/routes/usage.d.ts +24 -24
  86. package/dist/apps/management/routes/webhooks.d.ts +158 -573
  87. package/dist/apps/management/routes/webhooks.d.ts.map +1 -1
  88. package/dist/apps/mpp/App.d.ts +12 -0
  89. package/dist/apps/mpp/App.d.ts.map +1 -1
  90. package/dist/apps/mpp/Mpp.d.ts +3 -0
  91. package/dist/apps/mpp/Mpp.d.ts.map +1 -1
  92. package/dist/apps/mpp/Mpp.js +3 -0
  93. package/dist/apps/mpp/Mpp.js.map +1 -1
  94. package/dist/apps/mpp/internal/Adapter.d.ts.map +1 -1
  95. package/dist/apps/mpp/internal/Adapter.js +9 -2
  96. package/dist/apps/mpp/internal/Adapter.js.map +1 -1
  97. package/dist/db/Db.d.ts +2 -2
  98. package/dist/db/Db.d.ts.map +1 -1
  99. package/dist/db/Db.js +25 -15
  100. package/dist/db/Db.js.map +1 -1
  101. package/dist/db/tables/fundingDeposits.d.ts +18 -1
  102. package/dist/db/tables/fundingDeposits.d.ts.map +1 -1
  103. package/dist/db/tables/fundingDeposits.js +46 -2
  104. package/dist/db/tables/fundingDeposits.js.map +1 -1
  105. package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
  106. package/dist/db/tables/sponsoredTransactions.js +4 -1
  107. package/dist/db/tables/sponsoredTransactions.js.map +1 -1
  108. package/dist/db/tables/webhookSubscriptions.d.ts +56 -3
  109. package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -1
  110. package/dist/db/tables/webhookSubscriptions.js +76 -3
  111. package/dist/db/tables/webhookSubscriptions.js.map +1 -1
  112. package/dist/internal/Auth.d.ts +4 -0
  113. package/dist/internal/Auth.d.ts.map +1 -1
  114. package/dist/internal/Auth.js +11 -2
  115. package/dist/internal/Auth.js.map +1 -1
  116. package/dist/internal/MetricSink.d.ts +112 -3
  117. package/dist/internal/MetricSink.d.ts.map +1 -1
  118. package/dist/internal/MetricSink.js +116 -3
  119. package/dist/internal/MetricSink.js.map +1 -1
  120. package/dist/internal/Schema.js +1 -1
  121. package/dist/internal/Schema.js.map +1 -1
  122. package/dist/internal/Tidx.d.ts +1 -3
  123. package/dist/internal/Tidx.d.ts.map +1 -1
  124. package/dist/internal/Tidx.js +1 -3
  125. package/dist/internal/Tidx.js.map +1 -1
  126. package/dist/internal/Viem.d.ts.map +1 -1
  127. package/dist/internal/Viem.js +2 -1
  128. package/dist/internal/Viem.js.map +1 -1
  129. package/dist/internal/WebhookDestination.d.ts +2 -0
  130. package/dist/internal/WebhookDestination.d.ts.map +1 -1
  131. package/dist/internal/WebhookDestination.js +11 -0
  132. package/dist/internal/WebhookDestination.js.map +1 -1
  133. package/dist/internal/WebhookTransfer.d.ts +55 -0
  134. package/dist/internal/WebhookTransfer.d.ts.map +1 -0
  135. package/dist/internal/WebhookTransfer.js +296 -0
  136. package/dist/internal/WebhookTransfer.js.map +1 -0
  137. package/dist/internal/Webhooks.d.ts +37 -0
  138. package/dist/internal/Webhooks.d.ts.map +1 -1
  139. package/dist/internal/Webhooks.js +87 -13
  140. package/dist/internal/Webhooks.js.map +1 -1
  141. package/dist/internal/funding/Provider.d.ts +10 -6
  142. package/dist/internal/funding/Provider.d.ts.map +1 -1
  143. package/dist/internal/funding/Provider.js +11 -7
  144. package/dist/internal/funding/Provider.js.map +1 -1
  145. package/dist/internal/funding/Reconciliation.d.ts.map +1 -1
  146. package/dist/internal/funding/Reconciliation.js +229 -16
  147. package/dist/internal/funding/Reconciliation.js.map +1 -1
  148. package/dist/internal/funding/providers/relay.d.ts +3 -0
  149. package/dist/internal/funding/providers/relay.d.ts.map +1 -1
  150. package/dist/internal/funding/providers/relay.js +18 -3
  151. package/dist/internal/funding/providers/relay.js.map +1 -1
  152. package/package.json +3 -3
  153. package/src/App.test.ts +32 -0
  154. package/src/App.ts +4 -1
  155. package/src/Client.test-d.ts +116 -38
  156. package/src/Webhooks.bench.ts +714 -0
  157. package/src/Webhooks.memory.bench.ts +250 -0
  158. package/src/Webhooks.test.ts +77 -6
  159. package/src/Webhooks.ts +83 -29
  160. package/src/apps/Zones.test.ts +5 -4
  161. package/src/apps/Zones.ts +9 -8
  162. package/src/apps/composition.test.ts +39 -10
  163. package/src/apps/data/FxOracle.test.ts +18 -0
  164. package/src/apps/data/FxOracle.ts +28 -5
  165. package/src/apps/data/routes/activities.test.ts +19 -0
  166. package/src/apps/data/routes/activities.ts +34 -30
  167. package/src/apps/data/routes/balances.test.ts +161 -10
  168. package/src/apps/data/routes/balances.ts +339 -147
  169. package/src/apps/data/routes/blocks.test.ts +12 -6
  170. package/src/apps/data/routes/earn.test.ts +286 -9
  171. package/src/apps/data/routes/earn.ts +841 -58
  172. package/src/apps/data/routes/exchanges.test.ts +7 -6
  173. package/src/apps/data/routes/exchanges.ts +124 -117
  174. package/src/apps/data/routes/receipts.test.ts +24 -7
  175. package/src/apps/data/routes/receipts.ts +67 -50
  176. package/src/apps/data/routes/tokens.test.ts +51 -0
  177. package/src/apps/data/routes/transactions.test.ts +42 -0
  178. package/src/apps/data/routes/transactions.ts +44 -39
  179. package/src/apps/data/routes/transfers.test.ts +402 -24
  180. package/src/apps/data/routes/transfers.ts +708 -101
  181. package/src/apps/data/routes/valuation.test.ts +26 -0
  182. package/src/apps/data/routes/valuation.ts +6 -2
  183. package/src/apps/data/routes/webhooks.test.ts +414 -130
  184. package/src/apps/data/routes/webhooks.ts +603 -419
  185. package/src/apps/data/routes/zones.ts +6 -1
  186. package/src/apps/funding/App.ts +20 -5
  187. package/src/apps/funding/routes/deposit-addresses.test.ts +43 -22
  188. package/src/apps/funding/routes/deposit-addresses.ts +34 -117
  189. package/src/apps/funding/routes/quotes.test.ts +1 -1
  190. package/src/apps/funding/routes/transfers.test.ts +6 -25
  191. package/src/apps/funding/routes/transfers.ts +5 -5
  192. package/src/apps/mpp/Mpp.test.ts +13 -1
  193. package/src/apps/mpp/Mpp.ts +8 -0
  194. package/src/apps/mpp/internal/Adapter.ts +10 -2
  195. package/src/db/Db.test.ts +40 -6
  196. package/src/db/Db.ts +42 -19
  197. package/src/db/tables/fundingDeposits.test.ts +101 -0
  198. package/src/db/tables/fundingDeposits.ts +71 -2
  199. package/src/db/tables/sponsoredTransactions.ts +9 -2
  200. package/src/db/tables/webhookSubscriptions.ts +116 -4
  201. package/src/internal/Auth.test.ts +28 -0
  202. package/src/internal/Auth.ts +15 -1
  203. package/src/internal/EarnVaults.test.ts +2 -2
  204. package/src/internal/MetricSink.test.ts +518 -0
  205. package/src/internal/MetricSink.ts +266 -9
  206. package/src/internal/Schema.ts +1 -1
  207. package/src/internal/Tidx.test.ts +4 -4
  208. package/src/internal/Tidx.ts +1 -3
  209. package/src/internal/Viem.test.ts +8 -0
  210. package/src/internal/Viem.ts +2 -1
  211. package/src/internal/WebhookDestination.test.ts +20 -0
  212. package/src/internal/WebhookDestination.ts +10 -0
  213. package/src/internal/WebhookTransfer.ts +426 -0
  214. package/src/internal/Webhooks.test.ts +137 -0
  215. package/src/internal/Webhooks.ts +131 -17
  216. package/src/internal/funding/Provider.test.ts +47 -7
  217. package/src/internal/funding/Provider.ts +16 -11
  218. package/src/internal/funding/Reconciliation.local.test.ts +3 -1
  219. package/src/internal/funding/Reconciliation.test.ts +1532 -54
  220. package/src/internal/funding/Reconciliation.ts +296 -53
  221. package/src/internal/funding/SourceObservation.test.ts +3 -2
  222. package/src/internal/funding/providers/relay.test.ts +1 -4
  223. package/src/internal/funding/providers/relay.ts +23 -3
@@ -22,18 +22,35 @@ import * as Valuation from './valuation.js';
22
22
  // signature exposes a decoded `Transfer` CTE (with `from`/`to`/`value`) to
23
23
  // the indexer SQL.
24
24
  export const signature = 'event Transfer(address indexed from, address indexed to, uint256 value)';
25
- // TIP-20 contracts occupy the deterministic `0x20c0…` range. Full-width
26
- // bounds compare correctly as PostgreSQL bytea and ClickHouse hex strings.
27
- const tip20AddressRange = [
25
+ /**
26
+ * Half-open `[start, end)` bounds of the deterministic TIP-20 contract range.
27
+ * Full-width so the values compare correctly as PostgreSQL bytea, ClickHouse
28
+ * hex strings, and lowercased JavaScript strings.
29
+ */
30
+ export const tip20AddressRange = [
28
31
  '0x20c0000000000000000000000000000000000000',
29
32
  '0x20c1000000000000000000000000000000000000',
30
33
  ];
34
+ // The global feed spans every TIP-20 token, so its newest-first query cannot
35
+ // follow `token_transfers`' token-first sort key. Block windows bound the sort.
36
+ const transferWindowBlocks = 100_000;
37
+ // Sparse global scans retain progress without monopolizing one request.
38
+ const transferWindowScanCap = 8;
39
+ // Recipient-first decoded scans usually fill a page immediately. A larger raw
40
+ // batch leaves room to discard non-TIP-20 `Transfer` events without extra I/O.
41
+ const decodedTransferBatchSize = 256;
42
+ // Bound decoded recipient traversal while preserving the largest positional
43
+ // page and its extra row for `nextCursor` detection.
44
+ const decodedTransferRowCap = Schema.maxPageWindow + 1;
31
45
  // A memo'd TIP-20 transfer emits `TransferWithMemo` alongside the plain
32
46
  // `Transfer`; the two rows match on `(tx_hash, from, to, amount, token)`. A
33
47
  // text memo is decoded into `memo`; an MPP attribution fingerprint memo is
34
48
  // resolved into `attribution` instead.
35
49
  const memoSignature = 'event TransferWithMemo(address indexed from, address indexed to, uint256 amount, bytes32 indexed memo)';
36
50
  const tokenFields = ['token.logoUri', 'token.verified'];
51
+ // Concurrent metadata reads collapse into one deployless multicall. Small
52
+ // chunks keep each call under the Tempo RPC's per-call size limit.
53
+ const tokenReadChunkSize = 10;
37
54
  /** Zod schemas owned by the transfer handlers. */
38
55
  export var schema;
39
56
  (function (schema) {
@@ -239,8 +256,8 @@ export function transfers(options = {}) {
239
256
  });
240
257
  const options = c.req.valid('query');
241
258
  const chainId = options.chainId ?? c.get('chainId');
242
- const denomination = options['valuation.currency'] ?? 'USD';
243
- // Cursor pages carry current valuations and curated verification data.
259
+ const denomination = options['valuation.currency'];
260
+ // Cursor pages can carry current valuations and curated verification data.
244
261
  if (options.cursor !== undefined)
245
262
  Cache.setPolicy(c, Cache.policies.metadata);
246
263
  // Filters shared by the page query and the opt-in total count, so the
@@ -329,49 +346,58 @@ export function transfers(options = {}) {
329
346
  return suppress.has(key) ? [] : [transfer];
330
347
  });
331
348
  }
332
- // Verification is ancillary to the feed. Skip empty pages and degrade
333
- // cold snapshot failures to null valuations.
334
- const snapshot = data.length > 0
335
- ? await VerifiedTokens.snapshot(c, chainId).catch(() => undefined)
336
- : undefined;
337
349
  const includeLogoUri = options.include.includes('token.logoUri');
338
350
  const includeVerified = options.include.includes('token.verified');
351
+ // Curated data is needed only for requested valuation or token fields.
352
+ const snapshot = data.length > 0 && (denomination !== undefined || includeLogoUri || includeVerified)
353
+ ? await VerifiedTokens.snapshot(c, chainId).catch(() => undefined)
354
+ : undefined;
339
355
  const uniqueTokens = Array.from(new Set(data.flatMap((transfer) => [
340
356
  transfer.sourceToken.address,
341
357
  ...(transfer.destinationToken ? [transfer.destinationToken.address] : []),
342
358
  ])));
343
- const tokensByAddress = new Map(await Timing.time(c, 'tokens', () => Promise.all(uniqueTokens.map(async (token) => {
344
- const [logoUri, metadata] = await Promise.all([
345
- includeLogoUri
346
- ? Timing.time(c, 'token_logo', () => Tokens.getTokenLogo(c, { address: token, chainId })).catch(() => undefined)
347
- : undefined,
348
- Timing.time(c, 'token_metadata', () => Tokens.getTokenMetadata(c, { address: token, chainId })),
349
- ]);
350
- return [
351
- token.toLowerCase(),
352
- {
353
- ...metadata,
354
- logoUri: includeLogoUri
355
- ? (logoUri ??
356
- snapshot?.byAddress.get(token.toLowerCase())?.logoUri ??
357
- metadata.logoUri)
358
- : undefined,
359
- },
360
- ];
361
- }))));
359
+ const tokensByAddress = new Map();
360
+ await Timing.time(c, 'tokens', async () => {
361
+ for (let index = 0; index < uniqueTokens.length; index += tokenReadChunkSize) {
362
+ const group = uniqueTokens.slice(index, index + tokenReadChunkSize);
363
+ const entries = await Promise.all(group.map(async (token) => {
364
+ const [logoUri, metadata] = await Promise.all([
365
+ includeLogoUri
366
+ ? Timing.time(c, 'token_logo', () => Tokens.getTokenLogo(c, { address: token, chainId })).catch(() => undefined)
367
+ : undefined,
368
+ Timing.time(c, 'token_metadata', () => Tokens.getTokenMetadata(c, { address: token, chainId })),
369
+ ]);
370
+ return [
371
+ token.toLowerCase(),
372
+ {
373
+ ...metadata,
374
+ logoUri: includeLogoUri
375
+ ? (logoUri ??
376
+ snapshot?.byAddress.get(token.toLowerCase())?.logoUri ??
377
+ metadata.logoUri)
378
+ : undefined,
379
+ },
380
+ ];
381
+ }));
382
+ for (const entry of entries)
383
+ tokensByAddress.set(...entry);
384
+ }
385
+ });
362
386
  // Value each leg via its curated display currency, in the requested
363
387
  // denomination.
364
- const rates = await Valuation.ratesFor(c, {
365
- currencies: data.flatMap((transfer) => [
366
- transfer.sourceToken.address,
367
- ...(transfer.destinationToken ? [transfer.destinationToken.address] : []),
368
- ].flatMap((address) => {
369
- const held = snapshot?.byAddress.get(address.toLowerCase())?.currency;
370
- return held === undefined ? [] : [held];
371
- })),
372
- denomination,
373
- oracle,
374
- });
388
+ const rates = denomination
389
+ ? await Valuation.ratesFor(c, {
390
+ currencies: data.flatMap((transfer) => [
391
+ transfer.sourceToken.address,
392
+ ...(transfer.destinationToken ? [transfer.destinationToken.address] : []),
393
+ ].flatMap((address) => {
394
+ const held = snapshot?.byAddress.get(address.toLowerCase())?.currency;
395
+ return held === undefined ? [] : [held];
396
+ })),
397
+ denomination,
398
+ oracle,
399
+ })
400
+ : undefined;
375
401
  const enrich = (leg) => {
376
402
  const metadata = tokensByAddress.get(leg.address.toLowerCase());
377
403
  if (!metadata)
@@ -383,12 +409,16 @@ export function transfers(options = {}) {
383
409
  currency: metadata.currency,
384
410
  decimals: metadata.decimals,
385
411
  }),
386
- valuation: Valuation.valuationFor({
387
- amount: BigInt(leg.amount),
388
- denomination,
389
- rates,
390
- token: snapshot?.byAddress.get(leg.address.toLowerCase()),
391
- }),
412
+ ...(denomination
413
+ ? {
414
+ valuation: Valuation.valuationFor({
415
+ amount: BigInt(leg.amount),
416
+ denomination,
417
+ rates,
418
+ token: snapshot?.byAddress.get(leg.address.toLowerCase()),
419
+ }),
420
+ }
421
+ : {}),
392
422
  },
393
423
  token: {
394
424
  address: leg.address,
@@ -463,7 +493,7 @@ export function query(c, options) {
463
493
  const { chainId, timing, ...rest } = options;
464
494
  const store = c.get('store');
465
495
  const tidx = c.get('getTidx')(chainId);
466
- const cursor = options.cursor ? Cursor.decode(options.cursor, ['int', 'int']) : undefined;
496
+ const cursor = decodeTransferCursor(options.cursor);
467
497
  return Timing.time(c, timing, () => Store.memoize(async () => {
468
498
  const page = await scan({ tidx }, { chainId, ...rest });
469
499
  return {
@@ -471,13 +501,13 @@ export function query(c, options) {
471
501
  nextCursor: page.nextCursor,
472
502
  };
473
503
  }, {
474
- key: `transfers:v2:${chainId}:${options.order}:${options.token ?? ''}:${options.eitherSide ?? ''}:${options.sender ?? ''}:${options.recipient ?? ''}:${options.fromBlock ?? ''}:${options.toBlock ?? ''}:${options.fromTimestamp ?? ''}:${options.toTimestamp ?? ''}:${cursor ? `cursor:${cursor[0]}:${cursor[1]}` : options.page !== undefined && options.page > 1 ? `page:${options.page}` : 'head'}:${options.limit}`,
504
+ key: `transfers:v2:${chainId}:${options.order}:${options.token ?? ''}:${options.eitherSide ?? ''}:${options.sender ?? ''}:${options.recipient ?? ''}:${options.fromBlock ?? ''}:${options.toBlock ?? ''}:${options.fromTimestamp ?? ''}:${options.toTimestamp ?? ''}:${cursor ? `cursor:${cursor.position[0]}:${cursor.position[1]}:${cursor.offset}` : options.page !== undefined && options.page > 1 ? `page:${options.page}` : 'head'}:${options.limit}`,
475
505
  store,
476
506
  ttl: Ttl.seconds(15),
477
507
  }));
478
508
  }
479
509
  /**
480
- * Context-free core of {@link query}: reads token-scoped rows from
510
+ * Context-free core of {@link query}: reads token-scoped and global rows from
481
511
  * `token_transfers`, otherwise the decoded `Transfer` CTE, then paginates and
482
512
  * decodes them. Also used by the webhook poller.
483
513
  */
@@ -489,46 +519,193 @@ export async function scan(deps, options) {
489
519
  // Keyset pagination: anchor the page below the previous row's `(block, log_idx)`
490
520
  // position instead of a numeric offset, so rows arriving at the head can't
491
521
  // shift items across pages. A malformed cursor falls back to the head page.
492
- const cursor = options.cursor ? Cursor.decode(options.cursor, ['int', 'int']) : undefined;
522
+ const cursor = decodeTransferCursor(options.cursor);
493
523
  // Bounded positional lane (exclusive with `cursor` at the schema): page 1 is
494
524
  // the head page, so it shares the head's SQL and cache entries; deeper pages
495
525
  // translate to a SQL OFFSET of `(page - 1) * limit` rows.
496
- const offset = options.page !== undefined && options.page > 1 ? (options.page - 1) * limit : undefined;
526
+ const pageOffset = options.page !== undefined && options.page > 1 ? (options.page - 1) * limit : undefined;
527
+ const offset = pageOffset ?? (cursor?.offset === 0 ? undefined : cursor?.offset);
497
528
  const address = options.eitherSide;
529
+ const addressScoped = address !== undefined;
530
+ const recipient = options.recipient;
531
+ const recipientDecoded = !tokenScoped && recipient !== undefined;
532
+ const decoded = !tokenScoped && (addressScoped || recipientDecoded);
533
+ const splitAddress = addressScoped && !recipientDecoded;
534
+ // Only one-sided `"from"`/`"to"` equality shapes hit the planner's
535
+ // deterministic top-N kill, so only they take the capped fallback.
536
+ const sideFiltered = addressScoped || options.sender !== undefined || options.recipient !== undefined;
537
+ const global = !tokenScoped && !sideFiltered;
498
538
  // Transfer events filtered by any combination of contract / sender /
499
539
  // recipient / block / timestamp. The address shortcut is split below so
500
540
  // each upstream query can use one indexed side.
501
- const filters = filterClauses(options);
502
- if (cursor !== undefined)
541
+ const filters = global
542
+ ? [
543
+ `token >= '${tip20AddressRange[0]}'`,
544
+ `token < '${tip20AddressRange[1]}'`,
545
+ ...(options.fromBlock !== undefined ? [`block_num >= ${options.fromBlock}`] : []),
546
+ ...(options.toBlock !== undefined ? [`block_num <= ${options.toBlock}`] : []),
547
+ ...(options.fromTimestamp !== undefined
548
+ ? [`block_timestamp >= '${formatClickHouseDateTime(options.fromTimestamp)}'`]
549
+ : []),
550
+ ...(options.toTimestamp !== undefined
551
+ ? [`block_timestamp <= '${formatClickHouseDateTime(options.toTimestamp)}'`]
552
+ : []),
553
+ ]
554
+ : decoded
555
+ ? [
556
+ ...(options.sender !== undefined ? [`"from" = '${options.sender}'`] : []),
557
+ ...(recipient !== undefined ? [`"to" = '${recipient}'`] : []),
558
+ ...(options.fromBlock !== undefined ? [`block_num >= ${options.fromBlock}`] : []),
559
+ ...(options.toBlock !== undefined ? [`block_num <= ${options.toBlock}`] : []),
560
+ ...(options.fromTimestamp !== undefined
561
+ ? [`block_timestamp >= '${options.fromTimestamp}'`]
562
+ : []),
563
+ ...(options.toTimestamp !== undefined
564
+ ? [`block_timestamp <= '${options.toTimestamp}'`]
565
+ : []),
566
+ ]
567
+ : filterClauses(options);
568
+ if (cursor !== undefined && !decoded)
503
569
  filters.push(Cursor.keyset([
504
- { literal: Cursor.literal(cursor[0], 'int'), name: 'block_num', order },
505
- { literal: Cursor.literal(cursor[1], 'int'), name: 'log_idx', order },
570
+ { literal: Cursor.literal(cursor.position[0], 'int'), name: 'block_num', order },
571
+ { literal: Cursor.literal(cursor.position[1], 'int'), name: 'log_idx', order },
506
572
  ]));
507
- const addressScoped = address !== undefined;
508
573
  const window = addressScoped ? (offset ?? 0) + limit + 1 : limit + 1;
509
- // Only one-sided `"from"`/`"to"` equality shapes hit the planner's
510
- // deterministic top-N kill, so only they take the capped fallback.
511
- const sideFiltered = addressScoped || options.sender !== undefined || options.recipient !== undefined;
512
- const fetchRows = async (side) => {
574
+ const fetchRows = async (options_fetch = {}) => {
575
+ const side = options_fetch.side;
513
576
  const sideFilters = side !== undefined && address !== undefined
514
577
  ? [`"${side}" = '${address}'`, ...filters]
515
578
  : filters;
516
- const where = sideFilters.length > 0 ? `WHERE ${sideFilters.join(' AND ')}` : '';
517
- const select = `"from", "to", ${tokenScoped ? 'token AS address, amount AS value' : 'address, value'}, tx_hash, block_num, log_idx, block_timestamp`;
518
- const table = tokenScoped ? 'token_transfers' : 'Transfer';
519
- try {
579
+ const queryFilters = [
580
+ ...sideFilters,
581
+ ...(options_fetch.lo !== undefined ? [`block_num >= ${options_fetch.lo}`] : []),
582
+ ...(options_fetch.hi !== undefined ? [`block_num <= ${options_fetch.hi}`] : []),
583
+ ];
584
+ const materialized = tokenScoped || global;
585
+ // Keep a distinct alias until tidx.ts parses ClickHouse timestamps as UTC.
586
+ const select = materialized
587
+ ? `"from", "to", token AS address, amount AS value, tx_hash, block_num, log_idx, toString(block_timestamp, 'UTC') AS block_timestamp_utc`
588
+ : `"from", "to", address, value, tx_hash, block_num, log_idx, block_timestamp`;
589
+ const table = materialized ? 'token_transfers' : 'Transfer';
590
+ const execute = async (filters_execute, queryLimit, queryOffset) => {
591
+ const where = filters_execute.length > 0 ? `WHERE ${filters_execute.join(' AND ')}` : '';
520
592
  const result = await tidx.fetch({
521
593
  chainId,
522
- ...(tokenScoped ? { engine: 'clickhouse' } : { signatures: [signature] }),
594
+ ...(materialized ? { engine: 'clickhouse' } : { signatures: [signature] }),
523
595
  query: `
524
596
  SELECT ${select}
525
597
  FROM ${table}
526
598
  ${where}
527
599
  ORDER BY block_num ${direction}, log_idx ${direction}
528
- LIMIT ${window}${!addressScoped && offset !== undefined ? ` OFFSET ${offset}` : ''}
600
+ LIMIT ${queryLimit}${queryOffset !== undefined ? ` OFFSET ${queryOffset}` : ''}
529
601
  `,
530
602
  });
531
- return { rows: result.rows, sample: false };
603
+ return result.rows;
604
+ };
605
+ if (decoded) {
606
+ const target = (offset ?? 0) + limit + 1;
607
+ // Filter the physical token column so ClickHouse can use the materialized table ordering.
608
+ const filters_materialized = [
609
+ `token >= '${tip20AddressRange[0]}'`,
610
+ `token < '${tip20AddressRange[1]}'`,
611
+ ...(options.sender !== undefined ? [`"from" = '${options.sender}'`] : []),
612
+ ...(recipient !== undefined ? [`"to" = '${recipient}'`] : []),
613
+ ...(options.fromBlock !== undefined ? [`block_num >= ${options.fromBlock}`] : []),
614
+ ...(options.toBlock !== undefined ? [`block_num <= ${options.toBlock}`] : []),
615
+ ...(options.fromTimestamp !== undefined
616
+ ? [`block_timestamp >= '${formatClickHouseDateTime(options.fromTimestamp)}'`]
617
+ : []),
618
+ ...(options.toTimestamp !== undefined
619
+ ? [`block_timestamp <= '${formatClickHouseDateTime(options.toTimestamp)}'`]
620
+ : []),
621
+ ];
622
+ if (cursor !== undefined)
623
+ filters_materialized.push(Cursor.keyset([
624
+ { literal: Cursor.literal(cursor.position[0], 'int'), name: 'block_num', order },
625
+ { literal: Cursor.literal(cursor.position[1], 'int'), name: 'log_idx', order },
626
+ ]));
627
+ const sideFilters_materialized = side !== undefined && address !== undefined
628
+ ? [`"${side}" = '${address}'`, ...filters_materialized]
629
+ : filters_materialized;
630
+ const where_materialized = sideFilters_materialized.length > 0 ? `WHERE ${sideFilters_materialized.join(' AND ')}` : '';
631
+ try {
632
+ const result = await tidx.fetch({
633
+ chainId,
634
+ engine: 'clickhouse',
635
+ query: `
636
+ SELECT DISTINCT "from", "to", token AS address, amount AS value, tx_hash, block_num, log_idx, toString(block_timestamp, 'UTC') AS block_timestamp_utc
637
+ FROM token_transfers
638
+ ${where_materialized}
639
+ ORDER BY block_num ${direction}, log_idx ${direction}
640
+ LIMIT ${target}
641
+ `,
642
+ });
643
+ return {
644
+ capped: false,
645
+ rows: result.rows.slice(splitAddress ? 0 : (offset ?? 0), target),
646
+ sample: false,
647
+ };
648
+ }
649
+ catch (error) {
650
+ // Older chains can reject materialized address scans, so retain the
651
+ // bounded decoded scan as a compatibility fallback.
652
+ if (!Tidx.isQueryRejection(error))
653
+ throw error;
654
+ }
655
+ const rows = [];
656
+ let anchor = cursor?.position;
657
+ let rawRowCount = 0;
658
+ while (rows.length < target && rawRowCount < decodedTransferRowCap) {
659
+ const batchLimit = Math.min(Math.max(target - rows.length, decodedTransferBatchSize), Schema.maxPageWindow, decodedTransferRowCap - rawRowCount);
660
+ const batch = await (async () => {
661
+ if (anchor === undefined)
662
+ return execute(queryFilters, batchLimit);
663
+ // Signature-decoded queries reject or mis-plan tuple keysets. Read
664
+ // the cursor block and strict outer block range separately.
665
+ const block = Cursor.literal(anchor[0], 'int');
666
+ const index = Cursor.literal(anchor[1], 'int');
667
+ const comparison = order === 'asc' ? '>' : '<';
668
+ const sameBlock = await execute([...queryFilters, `block_num = ${block}`, `log_idx ${comparison} ${index}`], batchLimit);
669
+ if (sameBlock.length >= batchLimit)
670
+ return sameBlock;
671
+ const outerBlocks = await execute([...queryFilters, `block_num ${comparison} ${block}`], batchLimit - sameBlock.length);
672
+ return [...sameBlock, ...outerBlocks];
673
+ })();
674
+ rawRowCount += batch.length;
675
+ rows.push(...batch.filter(isTip20TransferRow));
676
+ if (batch.length < batchLimit)
677
+ break;
678
+ const last = batch.at(-1);
679
+ const block = Value.toNumber(last?.['block_num']);
680
+ const index = Value.toNumber(last?.['log_idx']);
681
+ if (block === undefined || index === undefined)
682
+ break;
683
+ anchor = [block, index];
684
+ }
685
+ const capped = rows.length < target && rawRowCount >= decodedTransferRowCap;
686
+ return {
687
+ capped,
688
+ ...(capped && anchor !== undefined
689
+ ? {
690
+ resume: {
691
+ offset: Math.max(0, (offset ?? 0) - rows.length),
692
+ position: anchor,
693
+ },
694
+ }
695
+ : {}),
696
+ rows: rows.slice(splitAddress ? 0 : (offset ?? 0), target),
697
+ sample: false,
698
+ };
699
+ }
700
+ const queryLimit = options_fetch.limit ?? window;
701
+ const queryOffset = options_fetch.offset ?? (!addressScoped ? offset : undefined);
702
+ const where = queryFilters.length > 0 ? `WHERE ${queryFilters.join(' AND ')}` : '';
703
+ try {
704
+ return {
705
+ capped: false,
706
+ rows: await execute(queryFilters, queryLimit, queryOffset),
707
+ sample: false,
708
+ };
532
709
  }
533
710
  catch (error) {
534
711
  // The planner kills a one-sided top-N walk when its matches sit far
@@ -538,7 +715,7 @@ export async function scan(deps, options) {
538
715
  throw error;
539
716
  const result = await tidx.fetch({
540
717
  chainId,
541
- ...(tokenScoped ? { engine: 'clickhouse' } : { signatures: [signature] }),
718
+ ...(materialized ? { engine: 'clickhouse' } : { signatures: [signature] }),
542
719
  query: `
543
720
  SELECT ${select}
544
721
  FROM ${table}
@@ -546,15 +723,84 @@ export async function scan(deps, options) {
546
723
  LIMIT ${Schema.countCap}
547
724
  `,
548
725
  });
549
- return { rows: result.rows, sample: true };
726
+ return { capped: result.rows.length >= Schema.countCap, rows: result.rows, sample: true };
550
727
  }
551
728
  };
729
+ const windowed = global;
730
+ const fetchWindowedRows = async () => {
731
+ const indexedCeiling = options.toTimestamp === undefined
732
+ ? await indexedHead(tidx, { chainId })
733
+ : await indexedBlockAtOrBefore(tidx, { chainId, timestamp: options.toTimestamp });
734
+ if (indexedCeiling === undefined)
735
+ return { capped: false, rows: [], sample: false };
736
+ const floor = options.fromBlock ?? 0;
737
+ const ceiling = Math.min(options.toBlock ?? indexedCeiling, indexedCeiling);
738
+ const target = (offset ?? 0) + limit + 1;
739
+ const rows = [];
740
+ const cursorBlock = cursor?.position[0];
741
+ let resumePosition;
742
+ let scannedWindows = 0;
743
+ let anchor = order === 'asc'
744
+ ? Math.max(floor, cursorBlock ?? floor)
745
+ : Math.min(ceiling, cursorBlock ?? ceiling);
746
+ while (anchor >= floor &&
747
+ anchor <= ceiling &&
748
+ rows.length < target &&
749
+ scannedWindows < transferWindowScanCap) {
750
+ const window_lo = order === 'asc' ? anchor : Math.max(floor, anchor - transferWindowBlocks + 1);
751
+ const window_hi = order === 'desc' ? anchor : Math.min(ceiling, anchor + transferWindowBlocks - 1);
752
+ let windowOffset = 0;
753
+ while (rows.length < target) {
754
+ const queryLimit = Math.min(target - rows.length, Schema.maxPageWindow);
755
+ const result = await fetchRows({
756
+ hi: window_hi,
757
+ limit: queryLimit,
758
+ lo: window_lo,
759
+ offset: windowOffset,
760
+ });
761
+ rows.push(...result.rows);
762
+ windowOffset += result.rows.length;
763
+ if (result.rows.length < queryLimit)
764
+ break;
765
+ }
766
+ scannedWindows++;
767
+ // Resume after the fully scanned boundary without skipping the next block.
768
+ resumePosition = order === 'asc' ? [window_hi, Number.MAX_SAFE_INTEGER] : [window_lo, 0];
769
+ anchor = order === 'asc' ? window_hi + 1 : window_lo - 1;
770
+ }
771
+ const capped = rows.length < target &&
772
+ anchor >= floor &&
773
+ anchor <= ceiling &&
774
+ scannedWindows >= transferWindowScanCap;
775
+ return {
776
+ capped,
777
+ ...(capped && resumePosition !== undefined
778
+ ? {
779
+ resume: {
780
+ offset: Math.max(0, (offset ?? 0) - rows.length),
781
+ position: resumePosition,
782
+ },
783
+ }
784
+ : {}),
785
+ rows: rows.slice(offset ?? 0),
786
+ sample: false,
787
+ };
788
+ };
789
+ // With `to = recipient`, the either-side address is redundant when equal
790
+ // and otherwise reduces to `from = address`, so one ordered stream suffices.
791
+ const recipientAddressSide = recipientDecoded && address !== undefined && address !== recipient ? 'from' : undefined;
552
792
  // TIDX cannot plan `(from = X OR to = X)` over the decoded Transfer table.
553
- // Fetch one bounded, server-filtered stream per side and collate app-side.
554
- const fetched = addressScoped
555
- ? await Promise.all([fetchRows('from'), fetchRows('to')])
556
- : [await fetchRows()];
557
- const { capped, rows } = collate(fetched, { limit: limit + 1, offset: offset ?? 0, order });
793
+ // Fetch one stream per side only when windowing or recipient reduction does not apply.
794
+ const fetched = windowed
795
+ ? [await fetchWindowedRows()]
796
+ : splitAddress
797
+ ? await Promise.all([fetchRows({ side: 'from' }), fetchRows({ side: 'to' })])
798
+ : [await fetchRows(recipientAddressSide === undefined ? {} : { side: recipientAddressSide })];
799
+ const { capped, resume, rows } = collate(fetched, {
800
+ limit: limit + 1,
801
+ offset: offset ?? 0,
802
+ order,
803
+ });
558
804
  // The next page anchors below the last fetched row's `(block, log_idx)`.
559
805
  const page = Cursor.paginate({
560
806
  rows,
@@ -574,7 +820,7 @@ export async function scan(deps, options) {
574
820
  const amount = Value.toIntegerString(row['value']);
575
821
  const blockNumber = Value.toNumber(row['block_num']);
576
822
  const logIndex = Value.toNumber(row['log_idx']);
577
- const timestamp = Value.toIsoDateTime(row['block_timestamp']);
823
+ const timestamp = Value.toIsoDateTime(row['block_timestamp_utc'] ?? row['block_timestamp']);
578
824
  if (tokenAddress.success &&
579
825
  from.success &&
580
826
  to.success &&
@@ -597,29 +843,103 @@ export async function scan(deps, options) {
597
843
  },
598
844
  });
599
845
  }
600
- // A truncated sample cannot anchor keyset pagination without silently
601
- // skipping unseen matches, so the page ends the list instead.
846
+ // Every matching row through `resume.position` was inspected, so advancing
847
+ // there cannot skip a match. Positional continuations retain their offset.
848
+ const nextCursor = resume
849
+ ? Cursor.encode(resume.offset > 0 ? [...resume.position, resume.offset] : [...resume.position])
850
+ : capped
851
+ ? null
852
+ : page.nextCursor;
602
853
  return {
603
854
  capped,
604
- hasMore: capped ? false : page.hasMore,
855
+ hasMore: resume ? true : capped ? false : page.hasMore,
605
856
  items,
606
857
  limit,
607
- nextCursor: capped ? null : page.nextCursor,
858
+ nextCursor,
859
+ ...(resume ? { resumeCursor: resume.position } : {}),
608
860
  };
609
861
  }
862
+ /** Reads the latest block available to TIDX for a descending window scan. */
863
+ async function indexedHead(tidx, options) {
864
+ const result = await tidx.fetch({
865
+ chainId: options.chainId,
866
+ query: 'SELECT num FROM blocks ORDER BY num DESC LIMIT 1',
867
+ });
868
+ return Value.toNumber(result.rows[0]?.['num']);
869
+ }
870
+ /** Reads the latest indexed block at or before a timestamp. */
871
+ async function indexedBlockAtOrBefore(tidx, options) {
872
+ const { chainId, timestamp } = options;
873
+ const result = await tidx.fetch({
874
+ chainId,
875
+ query: `
876
+ SELECT num
877
+ FROM blocks
878
+ WHERE timestamp <= '${Tidx.escape(timestamp)}'
879
+ ORDER BY num DESC
880
+ LIMIT 1
881
+ `,
882
+ });
883
+ return Value.toNumber(result.rows[0]?.['num']);
884
+ }
885
+ /** Formats an ISO timestamp for ClickHouse `DateTime64` comparisons. */
886
+ function formatClickHouseDateTime(value) {
887
+ return value.replace('T', ' ').replace(/Z$/, '');
888
+ }
610
889
  /**
611
890
  * Collates per-side transfer streams into one ordered page window: split and
612
891
  * sampled streams are deduped, globally ordered, and paged app-side, while a
613
- * lone engine-paged stream passes through. `capped` marks a fallback sample
614
- * that hit `Schema.countCap`: an arbitrary subset of that side's matches,
615
- * unfit to anchor pagination or exact counts.
892
+ * lone engine-paged stream passes through. Ordered capped streams may carry a
893
+ * safe raw continuation; unordered samples cannot.
616
894
  */
617
895
  export function collate(fetched, options) {
618
- const capped = fetched.some(({ rows, sample }) => sample && rows.length >= Schema.countCap);
896
+ const capped = fetched.some((stream) => stream.capped);
897
+ const cappedStreams = fetched.filter((stream) => stream.capped);
898
+ const resumes = cappedStreams.flatMap((stream) => (stream.resume ? [stream.resume] : []));
899
+ if (fetched.length > 1 &&
900
+ capped &&
901
+ !fetched.some((stream) => stream.sample) &&
902
+ resumes.length === cappedStreams.length) {
903
+ // Advance only through the least-progressed side, so the next strict
904
+ // keyset scan cannot rediscover any returned rows.
905
+ const position = resumes
906
+ .map((resume) => resume.position)
907
+ .reduce((candidate, current) => {
908
+ const comparison = candidate[0] - current[0] || candidate[1] - current[1];
909
+ if (options.order === 'asc')
910
+ return comparison <= 0 ? candidate : current;
911
+ return comparison >= 0 ? candidate : current;
912
+ });
913
+ const safeRows = fetched.flatMap((stream) => stream.rows.filter((row) => {
914
+ const blockNumber = Value.toNumber(row['block_num']);
915
+ const logIndex = Value.toNumber(row['log_idx']);
916
+ if (blockNumber === undefined || logIndex === undefined)
917
+ return false;
918
+ const comparison = blockNumber - position[0] || logIndex - position[1];
919
+ return options.order === 'asc' ? comparison <= 0 : comparison >= 0;
920
+ }));
921
+ const merged = mergeTransferRows(safeRows, {
922
+ limit: options.offset + options.limit,
923
+ offset: 0,
924
+ order: options.order,
925
+ });
926
+ const rows = merged.slice(options.offset, options.offset + options.limit);
927
+ if (rows.length >= options.limit)
928
+ return { capped: false, rows };
929
+ return {
930
+ capped: true,
931
+ resume: {
932
+ offset: Math.max(0, options.offset - merged.length),
933
+ position,
934
+ },
935
+ rows,
936
+ };
937
+ }
938
+ const resume = fetched.length === 1 ? fetched[0]?.resume : undefined;
619
939
  const rows = fetched.length > 1 || fetched.some(({ sample }) => sample)
620
940
  ? mergeTransferRows(fetched.flatMap(({ rows }) => rows), options)
621
941
  : (fetched[0]?.rows ?? []);
622
- return { capped, rows };
942
+ return { capped, ...(resume ? { resume } : {}), rows };
623
943
  }
624
944
  /** Deduplicates and globally orders bounded `from`/`to` transfer streams. */
625
945
  function mergeTransferRows(rows, options) {
@@ -643,12 +963,25 @@ function mergeTransferRows(rows, options) {
643
963
  });
644
964
  return positioned.slice(options.offset, options.offset + options.limit).map(({ row }) => row);
645
965
  }
966
+ /** Decodes ordinary transfer cursors and capped positional continuations. */
967
+ function decodeTransferCursor(token) {
968
+ if (token === undefined)
969
+ return undefined;
970
+ const position = Cursor.decode(token, ['int', 'int']);
971
+ if (position)
972
+ return { offset: 0, position: position };
973
+ const continuation = Cursor.decode(token, ['int', 'int', 'int']);
974
+ if (!continuation)
975
+ return undefined;
976
+ const [blockNumber, logIndex, offset] = continuation;
977
+ return { offset: Math.max(0, offset), position: [blockNumber, logIndex] };
978
+ }
646
979
  /**
647
980
  * Builds shared transfer filters. Token-scoped queries target `token_transfers`;
648
981
  * unscoped queries constrain the decoded `Transfer` emitter to TIP-20 contracts.
649
982
  */
650
983
  function filterClauses(options) {
651
- const { fromBlock, fromTimestamp, recipient, sender, toBlock, toTimestamp, token } = options;
984
+ const { fromBlock, fromTimestamp, recipient, sender, toBlock, token, toTimestamp } = options;
652
985
  const filters = token === undefined
653
986
  ? [`address >= '${tip20AddressRange[0]}'`, `address < '${tip20AddressRange[1]}'`]
654
987
  : [`token = '${token}'`];
@@ -666,6 +999,13 @@ function filterClauses(options) {
666
999
  filters.push(`block_timestamp <= '${toTimestamp}'`);
667
1000
  return filters;
668
1001
  }
1002
+ function isTip20TransferRow(row) {
1003
+ const address = row['address'];
1004
+ if (typeof address !== 'string')
1005
+ return false;
1006
+ const normalized = address.toLowerCase();
1007
+ return normalized >= tip20AddressRange[0] && normalized < tip20AddressRange[1];
1008
+ }
669
1009
  /**
670
1010
  * Capped total-row count for the `Transfer` feed, sharing {@link query}'s
671
1011
  * filters (minus pagination) so the count matches the page it annotates. Timed
@@ -698,14 +1038,181 @@ export async function countScan(deps, options) {
698
1038
  // rows, classify each transaction, and subtract the folded-away legs. Bounded
699
1039
  // by `countCap` like the raw count, so it stays within the indexer's budget.
700
1040
  if (options.crossToken) {
1041
+ const global = !tokenScoped &&
1042
+ address === undefined &&
1043
+ options.sender === undefined &&
1044
+ options.recipient === undefined;
1045
+ if (global) {
1046
+ // A capped count promises a conservative lower bound, so aggregate the
1047
+ // deduplicated capped sample without the global block sort or 10,000-row response.
1048
+ const materializedFilters = [
1049
+ `token >= '${tip20AddressRange[0]}'`,
1050
+ `token < '${tip20AddressRange[1]}'`,
1051
+ ...(options.fromBlock !== undefined ? [`block_num >= ${options.fromBlock}`] : []),
1052
+ ...(options.toBlock !== undefined ? [`block_num <= ${options.toBlock}`] : []),
1053
+ ...(options.fromTimestamp !== undefined
1054
+ ? [`block_timestamp >= '${options.fromTimestamp}'`]
1055
+ : []),
1056
+ ...(options.toTimestamp !== undefined
1057
+ ? [`block_timestamp <= '${options.toTimestamp}'`]
1058
+ : []),
1059
+ ];
1060
+ const result = await tidx.fetch({
1061
+ chainId,
1062
+ engine: 'clickhouse',
1063
+ query: `
1064
+ SELECT
1065
+ count(*) AS total,
1066
+ countIf(
1067
+ "to" = '${Addresses.stablecoinDex}'
1068
+ OR "from" = '${Addresses.stablecoinDex}'
1069
+ OR "to" = '${Addresses.feeManager}'
1070
+ ) AS suppressed
1071
+ FROM (
1072
+ SELECT tx_hash, log_idx, "from", "to"
1073
+ FROM token_transfers FINAL
1074
+ WHERE ${materializedFilters.join(' AND ')}
1075
+ LIMIT ${Schema.countCap}
1076
+ ) AS capped
1077
+ `,
1078
+ });
1079
+ const total = Value.toNumber(result.rows[0]?.['total']) ?? 0;
1080
+ const suppressed = Value.toNumber(result.rows[0]?.['suppressed']) ?? 0;
1081
+ if (total >= Schema.countCap) {
1082
+ return {
1083
+ totalCount: Math.max(0, total - suppressed),
1084
+ totalCountCapped: true,
1085
+ };
1086
+ }
1087
+ if (suppressed === 0)
1088
+ return { totalCount: total, totalCountCapped: false };
1089
+ }
1090
+ if (address !== undefined) {
1091
+ const materializedFilters = [
1092
+ ...(options.token === undefined
1093
+ ? [`token >= '${tip20AddressRange[0]}'`, `token < '${tip20AddressRange[1]}'`]
1094
+ : [`token = '${options.token}'`]),
1095
+ ...(options.sender !== undefined ? [`"from" = '${options.sender}'`] : []),
1096
+ ...(options.recipient !== undefined ? [`"to" = '${options.recipient}'`] : []),
1097
+ ...(options.fromBlock !== undefined ? [`block_num >= ${options.fromBlock}`] : []),
1098
+ ...(options.toBlock !== undefined ? [`block_num <= ${options.toBlock}`] : []),
1099
+ ...(options.fromTimestamp !== undefined
1100
+ ? [`block_timestamp >= '${options.fromTimestamp}'`]
1101
+ : []),
1102
+ ...(options.toTimestamp !== undefined
1103
+ ? [`block_timestamp <= '${options.toTimestamp}'`]
1104
+ : []),
1105
+ ];
1106
+ const sentFilters = [`"from" = '${address}'`, ...materializedFilters];
1107
+ const receivedFilters = [
1108
+ `"to" = '${address}'`,
1109
+ `"from" != '${address}'`,
1110
+ ...materializedFilters,
1111
+ ];
1112
+ const aggregate = await tidx.fetch({
1113
+ chainId,
1114
+ engine: 'clickhouse',
1115
+ query: `
1116
+ SELECT
1117
+ 'sent' AS side,
1118
+ count(*) AS total,
1119
+ countIf(
1120
+ "to" = '${Addresses.stablecoinDex}'
1121
+ OR "from" = '${Addresses.stablecoinDex}'
1122
+ OR "to" = '${Addresses.feeManager}'
1123
+ ) AS suppressed
1124
+ FROM (
1125
+ SELECT "from", "to"
1126
+ FROM token_transfers
1127
+ WHERE ${sentFilters.join(' AND ')}
1128
+ LIMIT ${Schema.countCap}
1129
+ ) AS sent
1130
+ UNION ALL
1131
+ SELECT
1132
+ 'received' AS side,
1133
+ count(*) AS total,
1134
+ countIf(
1135
+ "to" = '${Addresses.stablecoinDex}'
1136
+ OR "from" = '${Addresses.stablecoinDex}'
1137
+ OR "to" = '${Addresses.feeManager}'
1138
+ ) AS suppressed
1139
+ FROM (
1140
+ SELECT "from", "to"
1141
+ FROM token_transfers
1142
+ WHERE ${receivedFilters.join(' AND ')}
1143
+ LIMIT ${Schema.countCap}
1144
+ ) AS received
1145
+ `,
1146
+ });
1147
+ const sent = aggregate.rows.find((row) => Value.toText(row['side']) === 'sent');
1148
+ const received = aggregate.rows.find((row) => Value.toText(row['side']) === 'received');
1149
+ const sentTotal = Value.toNumber(sent?.['total']) ?? 0;
1150
+ const receivedTotal = Value.toNumber(received?.['total']) ?? 0;
1151
+ const total = sentTotal + receivedTotal;
1152
+ const suppressed = (Value.toNumber(sent?.['suppressed']) ?? 0) +
1153
+ (Value.toNumber(received?.['suppressed']) ?? 0);
1154
+ const capped = sentTotal >= Schema.countCap || receivedTotal >= Schema.countCap || total >= Schema.countCap;
1155
+ if (capped)
1156
+ return {
1157
+ totalCount: Math.min(Math.max(0, total - suppressed), Schema.countCap),
1158
+ totalCountCapped: true,
1159
+ };
1160
+ if (suppressed === 0)
1161
+ return { totalCount: total, totalCountCapped: false };
1162
+ // A below-cap aggregate proves these unsorted scans are complete, so the
1163
+ // exact bundle classifier does not need a global sort.
1164
+ const select = 'tx_hash, block_num, log_idx, "from", "to", token AS address, amount AS value';
1165
+ const fetchRows = async (sideFilters) => {
1166
+ const result = await tidx.fetch({
1167
+ chainId,
1168
+ engine: 'clickhouse',
1169
+ query: `
1170
+ SELECT ${select}
1171
+ FROM token_transfers
1172
+ WHERE ${sideFilters.join(' AND ')}
1173
+ LIMIT ${Schema.countCap}
1174
+ `,
1175
+ });
1176
+ return result.rows;
1177
+ };
1178
+ const [sentRows, receivedRows] = await Promise.all([
1179
+ fetchRows(sentFilters),
1180
+ fetchRows(receivedFilters),
1181
+ ]);
1182
+ const rows = [...sentRows, ...receivedRows];
1183
+ const transactionHashes = [...legsByTransaction(rows)]
1184
+ .filter(([, legs]) => legs.some(isBundleLeg))
1185
+ .map(([transactionHash]) => transactionHash);
1186
+ const transactionHashSet = new Set(transactionHashes);
1187
+ const candidateRows = rows.filter((row) => {
1188
+ const transactionHash = Value.toText(row['tx_hash']);
1189
+ return (transactionHash !== undefined && transactionHashSet.has(transactionHash.toLowerCase()));
1190
+ });
1191
+ const blocks = candidateRows.flatMap((row) => {
1192
+ const block = Value.toNumber(row['block_num']);
1193
+ return block === undefined ? [] : [block];
1194
+ });
1195
+ if (blocks.length === 0 || transactionHashes.length === 0)
1196
+ return foldCount(rows, { capped: false });
1197
+ // Address predicates omit sibling bundle legs. Load each candidate
1198
+ // transaction completely, then suppress only address-matched rows.
1199
+ const classification = await tidx.fetch({
1200
+ chainId,
1201
+ engine: 'clickhouse',
1202
+ query: `
1203
+ SELECT tx_hash, block_num, log_idx, "from", "to", token AS address, amount AS value
1204
+ FROM token_transfers
1205
+ WHERE block_num BETWEEN ${Math.min(...blocks)} AND ${Math.max(...blocks)}
1206
+ AND tx_hash IN (${transactionHashes.map((hash) => `'${hash}'`).join(', ')})
1207
+ `,
1208
+ });
1209
+ return foldCount(rows, { capped: false, classificationRows: classification.rows });
1210
+ }
701
1211
  // Only one-sided `"from"`/`"to"` equality shapes hit the planner's
702
1212
  // deterministic top-N kill, so only they take the capped fallback.
703
- const sideFiltered = address !== undefined || options.sender !== undefined || options.recipient !== undefined;
704
- const fetchRows = async (side) => {
705
- const sideFilters = side !== undefined && address !== undefined
706
- ? [`"${side}" = '${address}'`, ...filters]
707
- : filters;
708
- const sideWhere = sideFilters.length > 0 ? `WHERE ${sideFilters.join(' AND ')}` : '';
1213
+ const sideFiltered = options.sender !== undefined || options.recipient !== undefined;
1214
+ const fetchRows = async () => {
1215
+ const where = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '';
709
1216
  const select = `tx_hash, block_num, log_idx, "from", "to", ${tokenScoped ? 'token AS address, amount AS value' : 'address, value'}`;
710
1217
  const table = tokenScoped ? 'token_transfers' : 'Transfer';
711
1218
  try {
@@ -715,12 +1222,12 @@ export async function countScan(deps, options) {
715
1222
  query: `
716
1223
  SELECT ${select}
717
1224
  FROM ${table}
718
- ${sideWhere}
1225
+ ${where}
719
1226
  ORDER BY block_num, log_idx
720
1227
  LIMIT ${Schema.countCap}
721
1228
  `,
722
1229
  });
723
- return { rows: result.rows, sample: false };
1230
+ return { capped: false, rows: result.rows, sample: false };
724
1231
  }
725
1232
  catch (error) {
726
1233
  // Same planner kill as the page's side scan; the folded count is
@@ -733,16 +1240,14 @@ export async function countScan(deps, options) {
733
1240
  query: `
734
1241
  SELECT ${select}
735
1242
  FROM ${table}
736
- ${sideWhere}
1243
+ ${where}
737
1244
  LIMIT ${Schema.countCap}
738
1245
  `,
739
1246
  });
740
- return { rows: result.rows, sample: true };
1247
+ return { capped: result.rows.length >= Schema.countCap, rows: result.rows, sample: true };
741
1248
  }
742
1249
  };
743
- const fetched = address !== undefined
744
- ? await Promise.all([fetchRows('from'), fetchRows('to')])
745
- : [await fetchRows()];
1250
+ const fetched = [await fetchRows()];
746
1251
  const { capped, rows } = collate(fetched, { limit: Schema.countCap, offset: 0, order: 'asc' });
747
1252
  return foldCount(rows, { capped });
748
1253
  }
@@ -786,16 +1291,23 @@ export async function countScan(deps, options) {
786
1291
  * fee leg instead, keeping the reported lower bound from overcounting.
787
1292
  */
788
1293
  export function foldCount(rows, options) {
789
- const { capped } = options;
1294
+ const { capped, classificationRows = rows } = options;
1295
+ const rowKeys = new Set();
1296
+ for (const row of rows) {
1297
+ const transactionHash = Value.toText(row['tx_hash']);
1298
+ const logIndex = Value.toNumber(row['log_idx']);
1299
+ if (transactionHash && logIndex !== undefined)
1300
+ rowKeys.add(eventKey(transactionHash, logIndex));
1301
+ }
790
1302
  let suppressed = 0;
791
- for (const legs of legsByTransaction(rows).values()) {
1303
+ for (const [transactionHash, legs] of legsByTransaction(capped ? rows : classificationRows)) {
792
1304
  if (capped) {
793
1305
  suppressed += legs.filter(isBundleLeg).length;
794
1306
  continue;
795
1307
  }
796
1308
  const bundle = classifyCrossToken(legs);
797
1309
  if (bundle)
798
- suppressed += bundle.suppressLogIndexes.length;
1310
+ suppressed += bundle.suppressLogIndexes.filter((logIndex) => rowKeys.has(eventKey(transactionHash, logIndex))).length;
799
1311
  }
800
1312
  return {
801
1313
  totalCount: rows.length - suppressed,
@@ -876,13 +1388,14 @@ function isBundleLeg(leg) {
876
1388
  * transfer and is left untouched.
877
1389
  */
878
1390
  function classifyCrossToken(legs) {
879
- const toDex = legs.find((leg) => Address.isEqual(leg.to, Addresses.stablecoinDex));
880
- const fromDex = legs.find((leg) => Address.isEqual(leg.from, Addresses.stablecoinDex));
1391
+ const ordered = [...legs].sort((a, b) => a.logIndex - b.logIndex);
1392
+ const toDex = ordered.find((leg) => Address.isEqual(leg.to, Addresses.stablecoinDex));
1393
+ const fromDex = ordered.find((leg) => Address.isEqual(leg.from, Addresses.stablecoinDex));
881
1394
  if (!toDex || !fromDex)
882
1395
  return undefined;
883
1396
  const swapper = fromDex.to;
884
1397
  const destTokenAddress = fromDex.address;
885
- const delivery = legs.find((leg) => Address.isEqual(leg.address, destTokenAddress) &&
1398
+ const delivery = ordered.find((leg) => Address.isEqual(leg.address, destTokenAddress) &&
886
1399
  Address.isEqual(leg.from, swapper) &&
887
1400
  !Address.isEqual(leg.to, Addresses.stablecoinDex) &&
888
1401
  !Address.isEqual(leg.to, swapper) &&
@@ -890,7 +1403,7 @@ function classifyCrossToken(legs) {
890
1403
  // Require a token change so plain same-token DEX activity is not folded.
891
1404
  if (!delivery || Address.isEqual(toDex.address, delivery.address))
892
1405
  return undefined;
893
- const suppressLogIndexes = legs
1406
+ const suppressLogIndexes = ordered
894
1407
  .filter((leg) => leg.logIndex !== delivery.logIndex && isBundleLeg(leg))
895
1408
  .map((leg) => leg.logIndex);
896
1409
  return {