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
@@ -26,13 +26,31 @@ import * as Valuation from './valuation.js'
26
26
  // the indexer SQL.
27
27
  export const signature = 'event Transfer(address indexed from, address indexed to, uint256 value)'
28
28
 
29
- // TIP-20 contracts occupy the deterministic `0x20c0…` range. Full-width
30
- // bounds compare correctly as PostgreSQL bytea and ClickHouse hex strings.
31
- const tip20AddressRange = [
29
+ /**
30
+ * Half-open `[start, end)` bounds of the deterministic TIP-20 contract range.
31
+ * Full-width so the values compare correctly as PostgreSQL bytea, ClickHouse
32
+ * hex strings, and lowercased JavaScript strings.
33
+ */
34
+ export const tip20AddressRange = [
32
35
  '0x20c0000000000000000000000000000000000000',
33
36
  '0x20c1000000000000000000000000000000000000',
34
37
  ] as const
35
38
 
39
+ // The global feed spans every TIP-20 token, so its newest-first query cannot
40
+ // follow `token_transfers`' token-first sort key. Block windows bound the sort.
41
+ const transferWindowBlocks = 100_000
42
+
43
+ // Sparse global scans retain progress without monopolizing one request.
44
+ const transferWindowScanCap = 8
45
+
46
+ // Recipient-first decoded scans usually fill a page immediately. A larger raw
47
+ // batch leaves room to discard non-TIP-20 `Transfer` events without extra I/O.
48
+ const decodedTransferBatchSize = 256
49
+
50
+ // Bound decoded recipient traversal while preserving the largest positional
51
+ // page and its extra row for `nextCursor` detection.
52
+ const decodedTransferRowCap = Schema.maxPageWindow + 1
53
+
36
54
  // A memo'd TIP-20 transfer emits `TransferWithMemo` alongside the plain
37
55
  // `Transfer`; the two rows match on `(tx_hash, from, to, amount, token)`. A
38
56
  // text memo is decoded into `memo`; an MPP attribution fingerprint memo is
@@ -42,6 +60,10 @@ const memoSignature =
42
60
 
43
61
  const tokenFields = ['token.logoUri', 'token.verified'] as const
44
62
 
63
+ // Concurrent metadata reads collapse into one deployless multicall. Small
64
+ // chunks keep each call under the Tempo RPC's per-call size limit.
65
+ const tokenReadChunkSize = 10
66
+
45
67
  /** Zod schemas owned by the transfer handlers. */
46
68
  export namespace schema {
47
69
  /** A transfer token reference with RPC metadata. Inlined to avoid the `transfers`/`tokens` module cycle. */
@@ -363,9 +385,9 @@ export function transfers(options: transfers.Options = {}) {
363
385
 
364
386
  const options = c.req.valid('query')
365
387
  const chainId = options.chainId ?? c.get('chainId')
366
- const denomination = options['valuation.currency'] ?? 'USD'
388
+ const denomination = options['valuation.currency']
367
389
 
368
- // Cursor pages carry current valuations and curated verification data.
390
+ // Cursor pages can carry current valuations and curated verification data.
369
391
  if (options.cursor !== undefined) Cache.setPolicy(c, Cache.policies.metadata)
370
392
 
371
393
  // Filters shared by the page query and the opt-in total count, so the
@@ -465,14 +487,13 @@ export function transfers(options: transfers.Options = {}) {
465
487
  return suppress.has(key) ? [] : [transfer]
466
488
  })
467
489
  }
468
- // Verification is ancillary to the feed. Skip empty pages and degrade
469
- // cold snapshot failures to null valuations.
490
+ const includeLogoUri = options.include.includes('token.logoUri')
491
+ const includeVerified = options.include.includes('token.verified')
492
+ // Curated data is needed only for requested valuation or token fields.
470
493
  const snapshot =
471
- data.length > 0
494
+ data.length > 0 && (denomination !== undefined || includeLogoUri || includeVerified)
472
495
  ? await VerifiedTokens.snapshot(c, chainId).catch(() => undefined)
473
496
  : undefined
474
- const includeLogoUri = options.include.includes('token.logoUri')
475
- const includeVerified = options.include.includes('token.verified')
476
497
  const uniqueTokens = Array.from(
477
498
  new Set(
478
499
  data.flatMap((transfer) => [
@@ -481,10 +502,15 @@ export function transfers(options: transfers.Options = {}) {
481
502
  ]),
482
503
  ),
483
504
  )
484
- const tokensByAddress = new Map(
485
- await Timing.time(c, 'tokens', () =>
486
- Promise.all(
487
- uniqueTokens.map(async (token) => {
505
+ const tokensByAddress = new Map<
506
+ string,
507
+ Awaited<ReturnType<typeof Tokens.getTokenMetadata>>
508
+ >()
509
+ await Timing.time(c, 'tokens', async () => {
510
+ for (let index = 0; index < uniqueTokens.length; index += tokenReadChunkSize) {
511
+ const group = uniqueTokens.slice(index, index + tokenReadChunkSize)
512
+ const entries = await Promise.all(
513
+ group.map(async (token) => {
488
514
  const [logoUri, metadata] = await Promise.all([
489
515
  includeLogoUri
490
516
  ? Timing.time(c, 'token_logo', () =>
@@ -507,25 +533,28 @@ export function transfers(options: transfers.Options = {}) {
507
533
  },
508
534
  ] as const
509
535
  }),
510
- ),
511
- ),
512
- )
536
+ )
537
+ for (const entry of entries) tokensByAddress.set(...entry)
538
+ }
539
+ })
513
540
 
514
541
  // Value each leg via its curated display currency, in the requested
515
542
  // denomination.
516
- const rates = await Valuation.ratesFor(c, {
517
- currencies: data.flatMap((transfer) =>
518
- [
519
- transfer.sourceToken.address,
520
- ...(transfer.destinationToken ? [transfer.destinationToken.address] : []),
521
- ].flatMap((address) => {
522
- const held = snapshot?.byAddress.get(address.toLowerCase())?.currency
523
- return held === undefined ? [] : [held]
524
- }),
525
- ),
526
- denomination,
527
- oracle,
528
- })
543
+ const rates = denomination
544
+ ? await Valuation.ratesFor(c, {
545
+ currencies: data.flatMap((transfer) =>
546
+ [
547
+ transfer.sourceToken.address,
548
+ ...(transfer.destinationToken ? [transfer.destinationToken.address] : []),
549
+ ].flatMap((address) => {
550
+ const held = snapshot?.byAddress.get(address.toLowerCase())?.currency
551
+ return held === undefined ? [] : [held]
552
+ }),
553
+ ),
554
+ denomination,
555
+ oracle,
556
+ })
557
+ : undefined
529
558
  const enrich = (leg: CrossToken) => {
530
559
  const metadata = tokensByAddress.get(leg.address.toLowerCase())
531
560
  if (!metadata) throw new Error(`Token metadata unavailable for ${leg.address}`)
@@ -536,12 +565,16 @@ export function transfers(options: transfers.Options = {}) {
536
565
  currency: metadata.currency,
537
566
  decimals: metadata.decimals,
538
567
  }),
539
- valuation: Valuation.valuationFor({
540
- amount: BigInt(leg.amount),
541
- denomination,
542
- rates,
543
- token: snapshot?.byAddress.get(leg.address.toLowerCase()),
544
- }),
568
+ ...(denomination
569
+ ? {
570
+ valuation: Valuation.valuationFor({
571
+ amount: BigInt(leg.amount),
572
+ denomination,
573
+ rates,
574
+ token: snapshot?.byAddress.get(leg.address.toLowerCase()),
575
+ }),
576
+ }
577
+ : {}),
545
578
  },
546
579
  token: {
547
580
  address: leg.address,
@@ -632,7 +665,7 @@ export function query(c: Context<App.Environment>, options: query.Options) {
632
665
  const { chainId, timing, ...rest } = options
633
666
  const store = c.get('store')
634
667
  const tidx = c.get('getTidx')(chainId)
635
- const cursor = options.cursor ? Cursor.decode(options.cursor, ['int', 'int']) : undefined
668
+ const cursor = decodeTransferCursor(options.cursor)
636
669
 
637
670
  return Timing.time(c, timing, () =>
638
671
  Store.memoize(
@@ -644,7 +677,7 @@ export function query(c: Context<App.Environment>, options: query.Options) {
644
677
  }
645
678
  },
646
679
  {
647
- 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}`,
680
+ 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}`,
648
681
  store,
649
682
  ttl: Ttl.seconds(15),
650
683
  },
@@ -694,7 +727,7 @@ export declare namespace query {
694
727
  }
695
728
 
696
729
  /**
697
- * Context-free core of {@link query}: reads token-scoped rows from
730
+ * Context-free core of {@link query}: reads token-scoped and global rows from
698
731
  * `token_transfers`, otherwise the decoded `Transfer` CTE, then paginates and
699
732
  * decodes them. Also used by the webhook poller.
700
733
  */
@@ -706,52 +739,216 @@ export async function scan(deps: scan.Deps, options: scan.Options): Promise<scan
706
739
  // Keyset pagination: anchor the page below the previous row's `(block, log_idx)`
707
740
  // position instead of a numeric offset, so rows arriving at the head can't
708
741
  // shift items across pages. A malformed cursor falls back to the head page.
709
- const cursor = options.cursor ? Cursor.decode(options.cursor, ['int', 'int']) : undefined
742
+ const cursor = decodeTransferCursor(options.cursor)
710
743
  // Bounded positional lane (exclusive with `cursor` at the schema): page 1 is
711
744
  // the head page, so it shares the head's SQL and cache entries; deeper pages
712
745
  // translate to a SQL OFFSET of `(page - 1) * limit` rows.
713
- const offset =
746
+ const pageOffset =
714
747
  options.page !== undefined && options.page > 1 ? (options.page - 1) * limit : undefined
748
+ const offset = pageOffset ?? (cursor?.offset === 0 ? undefined : cursor?.offset)
715
749
  const address = options.eitherSide
750
+ const addressScoped = address !== undefined
751
+ const recipient = options.recipient
752
+ const recipientDecoded = !tokenScoped && recipient !== undefined
753
+ const decoded = !tokenScoped && (addressScoped || recipientDecoded)
754
+ const splitAddress = addressScoped && !recipientDecoded
755
+ // Only one-sided `"from"`/`"to"` equality shapes hit the planner's
756
+ // deterministic top-N kill, so only they take the capped fallback.
757
+ const sideFiltered =
758
+ addressScoped || options.sender !== undefined || options.recipient !== undefined
759
+ const global = !tokenScoped && !sideFiltered
716
760
 
717
761
  // Transfer events filtered by any combination of contract / sender /
718
762
  // recipient / block / timestamp. The address shortcut is split below so
719
763
  // each upstream query can use one indexed side.
720
- const filters = filterClauses(options)
721
- if (cursor !== undefined)
764
+ const filters = global
765
+ ? [
766
+ `token >= '${tip20AddressRange[0]}'`,
767
+ `token < '${tip20AddressRange[1]}'`,
768
+ ...(options.fromBlock !== undefined ? [`block_num >= ${options.fromBlock}`] : []),
769
+ ...(options.toBlock !== undefined ? [`block_num <= ${options.toBlock}`] : []),
770
+ ...(options.fromTimestamp !== undefined
771
+ ? [`block_timestamp >= '${formatClickHouseDateTime(options.fromTimestamp)}'`]
772
+ : []),
773
+ ...(options.toTimestamp !== undefined
774
+ ? [`block_timestamp <= '${formatClickHouseDateTime(options.toTimestamp)}'`]
775
+ : []),
776
+ ]
777
+ : decoded
778
+ ? [
779
+ ...(options.sender !== undefined ? [`"from" = '${options.sender}'`] : []),
780
+ ...(recipient !== undefined ? [`"to" = '${recipient}'`] : []),
781
+ ...(options.fromBlock !== undefined ? [`block_num >= ${options.fromBlock}`] : []),
782
+ ...(options.toBlock !== undefined ? [`block_num <= ${options.toBlock}`] : []),
783
+ ...(options.fromTimestamp !== undefined
784
+ ? [`block_timestamp >= '${options.fromTimestamp}'`]
785
+ : []),
786
+ ...(options.toTimestamp !== undefined
787
+ ? [`block_timestamp <= '${options.toTimestamp}'`]
788
+ : []),
789
+ ]
790
+ : filterClauses(options)
791
+ if (cursor !== undefined && !decoded)
722
792
  filters.push(
723
793
  Cursor.keyset([
724
- { literal: Cursor.literal(cursor[0]!, 'int'), name: 'block_num', order },
725
- { literal: Cursor.literal(cursor[1]!, 'int'), name: 'log_idx', order },
794
+ { literal: Cursor.literal(cursor.position[0], 'int'), name: 'block_num', order },
795
+ { literal: Cursor.literal(cursor.position[1], 'int'), name: 'log_idx', order },
726
796
  ]),
727
797
  )
728
- const addressScoped = address !== undefined
729
798
  const window = addressScoped ? (offset ?? 0) + limit + 1 : limit + 1
730
- // Only one-sided `"from"`/`"to"` equality shapes hit the planner's
731
- // deterministic top-N kill, so only they take the capped fallback.
732
- const sideFiltered =
733
- addressScoped || options.sender !== undefined || options.recipient !== undefined
734
- const fetchRows = async (side?: 'from' | 'to') => {
799
+ const fetchRows = async (options_fetch: fetchRows.Options = {}) => {
800
+ const side = options_fetch.side
735
801
  const sideFilters =
736
802
  side !== undefined && address !== undefined
737
803
  ? [`"${side}" = '${address}'`, ...filters]
738
804
  : filters
739
- const where = sideFilters.length > 0 ? `WHERE ${sideFilters.join(' AND ')}` : ''
740
- const select = `"from", "to", ${tokenScoped ? 'token AS address, amount AS value' : 'address, value'}, tx_hash, block_num, log_idx, block_timestamp`
741
- const table = tokenScoped ? 'token_transfers' : 'Transfer'
742
- try {
805
+ const queryFilters = [
806
+ ...sideFilters,
807
+ ...(options_fetch.lo !== undefined ? [`block_num >= ${options_fetch.lo}`] : []),
808
+ ...(options_fetch.hi !== undefined ? [`block_num <= ${options_fetch.hi}`] : []),
809
+ ]
810
+ const materialized = tokenScoped || global
811
+ // Keep a distinct alias until tidx.ts parses ClickHouse timestamps as UTC.
812
+ const select = materialized
813
+ ? `"from", "to", token AS address, amount AS value, tx_hash, block_num, log_idx, toString(block_timestamp, 'UTC') AS block_timestamp_utc`
814
+ : `"from", "to", address, value, tx_hash, block_num, log_idx, block_timestamp`
815
+ const table = materialized ? 'token_transfers' : 'Transfer'
816
+ const execute = async (
817
+ filters_execute: readonly string[],
818
+ queryLimit: number,
819
+ queryOffset?: number,
820
+ ): Promise<readonly Record<string, unknown>[]> => {
821
+ const where = filters_execute.length > 0 ? `WHERE ${filters_execute.join(' AND ')}` : ''
743
822
  const result = await tidx.fetch({
744
823
  chainId,
745
- ...(tokenScoped ? { engine: 'clickhouse' } : { signatures: [signature] }),
824
+ ...(materialized ? { engine: 'clickhouse' } : { signatures: [signature] }),
746
825
  query: `
747
826
  SELECT ${select}
748
827
  FROM ${table}
749
828
  ${where}
750
829
  ORDER BY block_num ${direction}, log_idx ${direction}
751
- LIMIT ${window}${!addressScoped && offset !== undefined ? ` OFFSET ${offset}` : ''}
830
+ LIMIT ${queryLimit}${queryOffset !== undefined ? ` OFFSET ${queryOffset}` : ''}
752
831
  ` as string,
753
832
  })
754
- return { rows: result.rows, sample: false }
833
+ return result.rows
834
+ }
835
+
836
+ if (decoded) {
837
+ const target = (offset ?? 0) + limit + 1
838
+ // Filter the physical token column so ClickHouse can use the materialized table ordering.
839
+ const filters_materialized = [
840
+ `token >= '${tip20AddressRange[0]}'`,
841
+ `token < '${tip20AddressRange[1]}'`,
842
+ ...(options.sender !== undefined ? [`"from" = '${options.sender}'`] : []),
843
+ ...(recipient !== undefined ? [`"to" = '${recipient}'`] : []),
844
+ ...(options.fromBlock !== undefined ? [`block_num >= ${options.fromBlock}`] : []),
845
+ ...(options.toBlock !== undefined ? [`block_num <= ${options.toBlock}`] : []),
846
+ ...(options.fromTimestamp !== undefined
847
+ ? [`block_timestamp >= '${formatClickHouseDateTime(options.fromTimestamp)}'`]
848
+ : []),
849
+ ...(options.toTimestamp !== undefined
850
+ ? [`block_timestamp <= '${formatClickHouseDateTime(options.toTimestamp)}'`]
851
+ : []),
852
+ ]
853
+ if (cursor !== undefined)
854
+ filters_materialized.push(
855
+ Cursor.keyset([
856
+ { literal: Cursor.literal(cursor.position[0], 'int'), name: 'block_num', order },
857
+ { literal: Cursor.literal(cursor.position[1], 'int'), name: 'log_idx', order },
858
+ ]),
859
+ )
860
+ const sideFilters_materialized =
861
+ side !== undefined && address !== undefined
862
+ ? [`"${side}" = '${address}'`, ...filters_materialized]
863
+ : filters_materialized
864
+ const where_materialized =
865
+ sideFilters_materialized.length > 0 ? `WHERE ${sideFilters_materialized.join(' AND ')}` : ''
866
+ try {
867
+ const result = await tidx.fetch({
868
+ chainId,
869
+ engine: 'clickhouse',
870
+ query: `
871
+ SELECT DISTINCT "from", "to", token AS address, amount AS value, tx_hash, block_num, log_idx, toString(block_timestamp, 'UTC') AS block_timestamp_utc
872
+ FROM token_transfers
873
+ ${where_materialized}
874
+ ORDER BY block_num ${direction}, log_idx ${direction}
875
+ LIMIT ${target}
876
+ ` as string,
877
+ })
878
+ return {
879
+ capped: false,
880
+ rows: result.rows.slice(splitAddress ? 0 : (offset ?? 0), target),
881
+ sample: false,
882
+ }
883
+ } catch (error) {
884
+ // Older chains can reject materialized address scans, so retain the
885
+ // bounded decoded scan as a compatibility fallback.
886
+ if (!Tidx.isQueryRejection(error)) throw error
887
+ }
888
+
889
+ const rows: Record<string, unknown>[] = []
890
+ let anchor = cursor?.position
891
+ let rawRowCount = 0
892
+ while (rows.length < target && rawRowCount < decodedTransferRowCap) {
893
+ const batchLimit = Math.min(
894
+ Math.max(target - rows.length, decodedTransferBatchSize),
895
+ Schema.maxPageWindow,
896
+ decodedTransferRowCap - rawRowCount,
897
+ )
898
+ const batch = await (async () => {
899
+ if (anchor === undefined) return execute(queryFilters, batchLimit)
900
+
901
+ // Signature-decoded queries reject or mis-plan tuple keysets. Read
902
+ // the cursor block and strict outer block range separately.
903
+ const block = Cursor.literal(anchor[0]!, 'int')
904
+ const index = Cursor.literal(anchor[1]!, 'int')
905
+ const comparison = order === 'asc' ? '>' : '<'
906
+ const sameBlock = await execute(
907
+ [...queryFilters, `block_num = ${block}`, `log_idx ${comparison} ${index}`],
908
+ batchLimit,
909
+ )
910
+ if (sameBlock.length >= batchLimit) return sameBlock
911
+ const outerBlocks = await execute(
912
+ [...queryFilters, `block_num ${comparison} ${block}`],
913
+ batchLimit - sameBlock.length,
914
+ )
915
+ return [...sameBlock, ...outerBlocks]
916
+ })()
917
+
918
+ rawRowCount += batch.length
919
+ rows.push(...batch.filter(isTip20TransferRow))
920
+ if (batch.length < batchLimit) break
921
+ const last = batch.at(-1)
922
+ const block = Value.toNumber(last?.['block_num'])
923
+ const index = Value.toNumber(last?.['log_idx'])
924
+ if (block === undefined || index === undefined) break
925
+ anchor = [block, index]
926
+ }
927
+ const capped = rows.length < target && rawRowCount >= decodedTransferRowCap
928
+ return {
929
+ capped,
930
+ ...(capped && anchor !== undefined
931
+ ? {
932
+ resume: {
933
+ offset: Math.max(0, (offset ?? 0) - rows.length),
934
+ position: anchor,
935
+ },
936
+ }
937
+ : {}),
938
+ rows: rows.slice(splitAddress ? 0 : (offset ?? 0), target),
939
+ sample: false,
940
+ }
941
+ }
942
+
943
+ const queryLimit = options_fetch.limit ?? window
944
+ const queryOffset = options_fetch.offset ?? (!addressScoped ? offset : undefined)
945
+ const where = queryFilters.length > 0 ? `WHERE ${queryFilters.join(' AND ')}` : ''
946
+ try {
947
+ return {
948
+ capped: false,
949
+ rows: await execute(queryFilters, queryLimit, queryOffset),
950
+ sample: false,
951
+ }
755
952
  } catch (error) {
756
953
  // The planner kills a one-sided top-N walk when its matches sit far
757
954
  // from head (422 `db error`). Retry as a plain capped scan, returning
@@ -759,7 +956,7 @@ export async function scan(deps: scan.Deps, options: scan.Options): Promise<scan
759
956
  if (!sideFiltered || !Tidx.isDeterministicError(error)) throw error
760
957
  const result = await tidx.fetch({
761
958
  chainId,
762
- ...(tokenScoped ? { engine: 'clickhouse' } : { signatures: [signature] }),
959
+ ...(materialized ? { engine: 'clickhouse' } : { signatures: [signature] }),
763
960
  query: `
764
961
  SELECT ${select}
765
962
  FROM ${table}
@@ -767,16 +964,93 @@ export async function scan(deps: scan.Deps, options: scan.Options): Promise<scan
767
964
  LIMIT ${Schema.countCap}
768
965
  ` as string,
769
966
  })
770
- return { rows: result.rows, sample: true }
967
+ return { capped: result.rows.length >= Schema.countCap, rows: result.rows, sample: true }
771
968
  }
772
969
  }
773
970
 
971
+ const windowed = global
972
+ const fetchWindowedRows = async () => {
973
+ const indexedCeiling =
974
+ options.toTimestamp === undefined
975
+ ? await indexedHead(tidx, { chainId })
976
+ : await indexedBlockAtOrBefore(tidx, { chainId, timestamp: options.toTimestamp })
977
+ if (indexedCeiling === undefined) return { capped: false, rows: [], sample: false }
978
+
979
+ const floor = options.fromBlock ?? 0
980
+ const ceiling = Math.min(options.toBlock ?? indexedCeiling, indexedCeiling)
981
+ const target = (offset ?? 0) + limit + 1
982
+ const rows: Record<string, unknown>[] = []
983
+ const cursorBlock = cursor?.position[0]
984
+ let resumePosition: readonly [number, number] | undefined
985
+ let scannedWindows = 0
986
+ let anchor =
987
+ order === 'asc'
988
+ ? Math.max(floor, cursorBlock ?? floor)
989
+ : Math.min(ceiling, cursorBlock ?? ceiling)
990
+ while (
991
+ anchor >= floor &&
992
+ anchor <= ceiling &&
993
+ rows.length < target &&
994
+ scannedWindows < transferWindowScanCap
995
+ ) {
996
+ const window_lo =
997
+ order === 'asc' ? anchor : Math.max(floor, anchor - transferWindowBlocks + 1)
998
+ const window_hi =
999
+ order === 'desc' ? anchor : Math.min(ceiling, anchor + transferWindowBlocks - 1)
1000
+ let windowOffset = 0
1001
+ while (rows.length < target) {
1002
+ const queryLimit = Math.min(target - rows.length, Schema.maxPageWindow)
1003
+ const result = await fetchRows({
1004
+ hi: window_hi,
1005
+ limit: queryLimit,
1006
+ lo: window_lo,
1007
+ offset: windowOffset,
1008
+ })
1009
+ rows.push(...result.rows)
1010
+ windowOffset += result.rows.length
1011
+ if (result.rows.length < queryLimit) break
1012
+ }
1013
+ scannedWindows++
1014
+ // Resume after the fully scanned boundary without skipping the next block.
1015
+ resumePosition = order === 'asc' ? [window_hi, Number.MAX_SAFE_INTEGER] : [window_lo, 0]
1016
+ anchor = order === 'asc' ? window_hi + 1 : window_lo - 1
1017
+ }
1018
+ const capped =
1019
+ rows.length < target &&
1020
+ anchor >= floor &&
1021
+ anchor <= ceiling &&
1022
+ scannedWindows >= transferWindowScanCap
1023
+ return {
1024
+ capped,
1025
+ ...(capped && resumePosition !== undefined
1026
+ ? {
1027
+ resume: {
1028
+ offset: Math.max(0, (offset ?? 0) - rows.length),
1029
+ position: resumePosition,
1030
+ },
1031
+ }
1032
+ : {}),
1033
+ rows: rows.slice(offset ?? 0),
1034
+ sample: false,
1035
+ }
1036
+ }
1037
+
1038
+ // With `to = recipient`, the either-side address is redundant when equal
1039
+ // and otherwise reduces to `from = address`, so one ordered stream suffices.
1040
+ const recipientAddressSide =
1041
+ recipientDecoded && address !== undefined && address !== recipient ? 'from' : undefined
774
1042
  // TIDX cannot plan `(from = X OR to = X)` over the decoded Transfer table.
775
- // Fetch one bounded, server-filtered stream per side and collate app-side.
776
- const fetched = addressScoped
777
- ? await Promise.all([fetchRows('from'), fetchRows('to')])
778
- : [await fetchRows()]
779
- const { capped, rows } = collate(fetched, { limit: limit + 1, offset: offset ?? 0, order })
1043
+ // Fetch one stream per side only when windowing or recipient reduction does not apply.
1044
+ const fetched = windowed
1045
+ ? [await fetchWindowedRows()]
1046
+ : splitAddress
1047
+ ? await Promise.all([fetchRows({ side: 'from' }), fetchRows({ side: 'to' })])
1048
+ : [await fetchRows(recipientAddressSide === undefined ? {} : { side: recipientAddressSide })]
1049
+ const { capped, resume, rows } = collate(fetched, {
1050
+ limit: limit + 1,
1051
+ offset: offset ?? 0,
1052
+ order,
1053
+ })
780
1054
 
781
1055
  // The next page anchors below the last fetched row's `(block, log_idx)`.
782
1056
  const page = Cursor.paginate({
@@ -798,7 +1072,7 @@ export async function scan(deps: scan.Deps, options: scan.Options): Promise<scan
798
1072
  const amount = Value.toIntegerString(row['value'])
799
1073
  const blockNumber = Value.toNumber(row['block_num'])
800
1074
  const logIndex = Value.toNumber(row['log_idx'])
801
- const timestamp = Value.toIsoDateTime(row['block_timestamp'])
1075
+ const timestamp = Value.toIsoDateTime(row['block_timestamp_utc'] ?? row['block_timestamp'])
802
1076
  if (
803
1077
  tokenAddress.success &&
804
1078
  from.success &&
@@ -824,29 +1098,141 @@ export async function scan(deps: scan.Deps, options: scan.Options): Promise<scan
824
1098
  })
825
1099
  }
826
1100
 
827
- // A truncated sample cannot anchor keyset pagination without silently
828
- // skipping unseen matches, so the page ends the list instead.
1101
+ // Every matching row through `resume.position` was inspected, so advancing
1102
+ // there cannot skip a match. Positional continuations retain their offset.
1103
+ const nextCursor = resume
1104
+ ? Cursor.encode(resume.offset > 0 ? [...resume.position, resume.offset] : [...resume.position])
1105
+ : capped
1106
+ ? null
1107
+ : page.nextCursor
829
1108
  return {
830
1109
  capped,
831
- hasMore: capped ? false : page.hasMore,
1110
+ hasMore: resume ? true : capped ? false : page.hasMore,
832
1111
  items,
833
1112
  limit,
834
- nextCursor: capped ? null : page.nextCursor,
1113
+ nextCursor,
1114
+ ...(resume ? { resumeCursor: resume.position } : {}),
1115
+ }
1116
+ }
1117
+
1118
+ declare namespace fetchRows {
1119
+ /** Source-side, block-window, and pagination constraints for one upstream scan. */
1120
+ type Options = {
1121
+ /** Inclusive highest block number. */
1122
+ hi?: number | undefined
1123
+ /** Maximum rows to return. */
1124
+ limit?: number | undefined
1125
+ /** Inclusive lowest block number. */
1126
+ lo?: number | undefined
1127
+ /** Rows to skip before returning results. */
1128
+ offset?: number | undefined
1129
+ /** Indexed event participant side. */
1130
+ side?: 'from' | 'to' | undefined
1131
+ }
1132
+ }
1133
+
1134
+ /** Reads the latest block available to TIDX for a descending window scan. */
1135
+ async function indexedHead(tidx: Tidx.Client, options: indexedHead.Options) {
1136
+ const result = await tidx.fetch({
1137
+ chainId: options.chainId,
1138
+ query: 'SELECT num FROM blocks ORDER BY num DESC LIMIT 1',
1139
+ })
1140
+ return Value.toNumber(result.rows[0]?.['num'])
1141
+ }
1142
+
1143
+ declare namespace indexedHead {
1144
+ /** Chain whose indexed head should be read. */
1145
+ type Options = {
1146
+ /** Target chain id. */
1147
+ chainId: number
1148
+ }
1149
+ }
1150
+
1151
+ /** Reads the latest indexed block at or before a timestamp. */
1152
+ async function indexedBlockAtOrBefore(tidx: Tidx.Client, options: indexedBlockAtOrBefore.Options) {
1153
+ const { chainId, timestamp } = options
1154
+ const result = await tidx.fetch({
1155
+ chainId,
1156
+ query: `
1157
+ SELECT num
1158
+ FROM blocks
1159
+ WHERE timestamp <= '${Tidx.escape(timestamp)}'
1160
+ ORDER BY num DESC
1161
+ LIMIT 1
1162
+ ` as string,
1163
+ })
1164
+ return Value.toNumber(result.rows[0]?.['num'])
1165
+ }
1166
+
1167
+ declare namespace indexedBlockAtOrBefore {
1168
+ /** Chain and cutoff for the highest qualifying indexed block. */
1169
+ type Options = {
1170
+ /** Target chain id. */
1171
+ chainId: number
1172
+ /** Inclusive timestamp cutoff. */
1173
+ timestamp: string
835
1174
  }
836
1175
  }
837
1176
 
1177
+ /** Formats an ISO timestamp for ClickHouse `DateTime64` comparisons. */
1178
+ function formatClickHouseDateTime(value: string) {
1179
+ return value.replace('T', ' ').replace(/Z$/, '')
1180
+ }
1181
+
838
1182
  /**
839
1183
  * Collates per-side transfer streams into one ordered page window: split and
840
1184
  * sampled streams are deduped, globally ordered, and paged app-side, while a
841
- * lone engine-paged stream passes through. `capped` marks a fallback sample
842
- * that hit `Schema.countCap`: an arbitrary subset of that side's matches,
843
- * unfit to anchor pagination or exact counts.
1185
+ * lone engine-paged stream passes through. Ordered capped streams may carry a
1186
+ * safe raw continuation; unordered samples cannot.
844
1187
  */
845
1188
  export function collate(
846
1189
  fetched: readonly collate.Stream[],
847
1190
  options: collate.Options,
848
1191
  ): collate.Result {
849
- const capped = fetched.some(({ rows, sample }) => sample && rows.length >= Schema.countCap)
1192
+ const capped = fetched.some((stream) => stream.capped)
1193
+ const cappedStreams = fetched.filter((stream) => stream.capped)
1194
+ const resumes = cappedStreams.flatMap((stream) => (stream.resume ? [stream.resume] : []))
1195
+ if (
1196
+ fetched.length > 1 &&
1197
+ capped &&
1198
+ !fetched.some((stream) => stream.sample) &&
1199
+ resumes.length === cappedStreams.length
1200
+ ) {
1201
+ // Advance only through the least-progressed side, so the next strict
1202
+ // keyset scan cannot rediscover any returned rows.
1203
+ const position = resumes
1204
+ .map((resume) => resume.position)
1205
+ .reduce((candidate, current) => {
1206
+ const comparison = candidate[0] - current[0] || candidate[1] - current[1]
1207
+ if (options.order === 'asc') return comparison <= 0 ? candidate : current
1208
+ return comparison >= 0 ? candidate : current
1209
+ })
1210
+ const safeRows = fetched.flatMap((stream) =>
1211
+ stream.rows.filter((row) => {
1212
+ const blockNumber = Value.toNumber(row['block_num'])
1213
+ const logIndex = Value.toNumber(row['log_idx'])
1214
+ if (blockNumber === undefined || logIndex === undefined) return false
1215
+ const comparison = blockNumber - position[0] || logIndex - position[1]
1216
+ return options.order === 'asc' ? comparison <= 0 : comparison >= 0
1217
+ }),
1218
+ )
1219
+ const merged = mergeTransferRows(safeRows, {
1220
+ limit: options.offset + options.limit,
1221
+ offset: 0,
1222
+ order: options.order,
1223
+ })
1224
+ const rows = merged.slice(options.offset, options.offset + options.limit)
1225
+ if (rows.length >= options.limit) return { capped: false, rows }
1226
+ return {
1227
+ capped: true,
1228
+ resume: {
1229
+ offset: Math.max(0, options.offset - merged.length),
1230
+ position,
1231
+ },
1232
+ rows,
1233
+ }
1234
+ }
1235
+ const resume = fetched.length === 1 ? fetched[0]?.resume : undefined
850
1236
  const rows =
851
1237
  fetched.length > 1 || fetched.some(({ sample }) => sample)
852
1238
  ? mergeTransferRows(
@@ -854,17 +1240,28 @@ export function collate(
854
1240
  options,
855
1241
  )
856
1242
  : (fetched[0]?.rows ?? [])
857
- return { capped, rows }
1243
+ return { capped, ...(resume ? { resume } : {}), rows }
858
1244
  }
859
1245
 
860
1246
  export declare namespace collate {
861
1247
  /** One fetched transfer stream. */
862
1248
  type Stream = {
1249
+ /** Whether upstream bounds truncated this stream. */
1250
+ capped: boolean
863
1251
  /** Raw TIDX rows. */
864
1252
  rows: readonly Record<string, unknown>[]
1253
+ /** Ordered raw position and unmatched positional offset after a bounded scan. */
1254
+ resume?: Resume | undefined
865
1255
  /** Whether the rows are an unordered, unpaged capped fallback sample. */
866
1256
  sample: boolean
867
1257
  }
1258
+ /** Progress retained after a bounded ordered scan. */
1259
+ type Resume = {
1260
+ /** Filtered rows that a positional continuation still needs to skip. */
1261
+ offset: number
1262
+ /** Last ordered position safely inspected by the bounded scan. */
1263
+ position: readonly [blockNumber: number, logIndex: number]
1264
+ }
868
1265
  /** The page window applied to the merged stream. */
869
1266
  type Options = {
870
1267
  /** Maximum merged rows to return. */
@@ -876,10 +1273,12 @@ export declare namespace collate {
876
1273
  }
877
1274
  /** The collated page window. */
878
1275
  type Result = {
879
- /** Whether any fallback sample hit `Schema.countCap`. */
1276
+ /** Whether an upstream work bound truncated any stream. */
880
1277
  capped: boolean
881
1278
  /** The ordered page window rows. */
882
1279
  rows: readonly Record<string, unknown>[]
1280
+ /** Ordered continuation state, absent for unordered capped samples. */
1281
+ resume?: Resume | undefined
883
1282
  }
884
1283
  }
885
1284
 
@@ -935,12 +1334,33 @@ export declare namespace scan {
935
1334
  }
936
1335
  /** A page of scanned `Transfer` rows. */
937
1336
  type Page = {
938
- /** Whether the page came from a cap-truncated fallback sample; such a page must not advance a poller cursor. */
1337
+ /** Whether an upstream work bound truncated the page. */
939
1338
  capped: boolean
940
1339
  hasMore: boolean
941
1340
  items: readonly Item[]
942
1341
  limit: number
943
1342
  nextCursor: string | null
1343
+ /** Last ordered position safely inspected, when a capped scan can resume. */
1344
+ resumeCursor?: readonly [blockNumber: number, logIndex: number] | undefined
1345
+ }
1346
+ }
1347
+
1348
+ /** Decodes ordinary transfer cursors and capped positional continuations. */
1349
+ function decodeTransferCursor(token: string | undefined): decodeTransferCursor.Result | undefined {
1350
+ if (token === undefined) return undefined
1351
+ const position = Cursor.decode(token, ['int', 'int'])
1352
+ if (position) return { offset: 0, position: position as [blockNumber: number, logIndex: number] }
1353
+ const continuation = Cursor.decode(token, ['int', 'int', 'int'])
1354
+ if (!continuation) return undefined
1355
+ const [blockNumber, logIndex, offset] = continuation as [number, number, number]
1356
+ return { offset: Math.max(0, offset), position: [blockNumber, logIndex] }
1357
+ }
1358
+
1359
+ declare namespace decodeTransferCursor {
1360
+ /** Decoded raw position plus any filtered rows still to skip. */
1361
+ type Result = {
1362
+ offset: number
1363
+ position: readonly [blockNumber: number, logIndex: number]
944
1364
  }
945
1365
  }
946
1366
 
@@ -949,7 +1369,7 @@ export declare namespace scan {
949
1369
  * unscoped queries constrain the decoded `Transfer` emitter to TIP-20 contracts.
950
1370
  */
951
1371
  function filterClauses(options: filterClauses.Options): string[] {
952
- const { fromBlock, fromTimestamp, recipient, sender, toBlock, toTimestamp, token } = options
1372
+ const { fromBlock, fromTimestamp, recipient, sender, toBlock, token, toTimestamp } = options
953
1373
  const filters =
954
1374
  token === undefined
955
1375
  ? [`address >= '${tip20AddressRange[0]}'`, `address < '${tip20AddressRange[1]}'`]
@@ -971,6 +1391,13 @@ declare namespace filterClauses {
971
1391
  >
972
1392
  }
973
1393
 
1394
+ function isTip20TransferRow(row: Record<string, unknown>) {
1395
+ const address = row['address']
1396
+ if (typeof address !== 'string') return false
1397
+ const normalized = address.toLowerCase()
1398
+ return normalized >= tip20AddressRange[0] && normalized < tip20AddressRange[1]
1399
+ }
1400
+
974
1401
  /**
975
1402
  * Capped total-row count for the `Transfer` feed, sharing {@link query}'s
976
1403
  * filters (minus pagination) so the count matches the page it annotates. Timed
@@ -1033,16 +1460,187 @@ export async function countScan(
1033
1460
  // rows, classify each transaction, and subtract the folded-away legs. Bounded
1034
1461
  // by `countCap` like the raw count, so it stays within the indexer's budget.
1035
1462
  if (options.crossToken) {
1463
+ const global =
1464
+ !tokenScoped &&
1465
+ address === undefined &&
1466
+ options.sender === undefined &&
1467
+ options.recipient === undefined
1468
+ if (global) {
1469
+ // A capped count promises a conservative lower bound, so aggregate the
1470
+ // deduplicated capped sample without the global block sort or 10,000-row response.
1471
+ const materializedFilters = [
1472
+ `token >= '${tip20AddressRange[0]}'`,
1473
+ `token < '${tip20AddressRange[1]}'`,
1474
+ ...(options.fromBlock !== undefined ? [`block_num >= ${options.fromBlock}`] : []),
1475
+ ...(options.toBlock !== undefined ? [`block_num <= ${options.toBlock}`] : []),
1476
+ ...(options.fromTimestamp !== undefined
1477
+ ? [`block_timestamp >= '${options.fromTimestamp}'`]
1478
+ : []),
1479
+ ...(options.toTimestamp !== undefined
1480
+ ? [`block_timestamp <= '${options.toTimestamp}'`]
1481
+ : []),
1482
+ ]
1483
+ const result = await tidx.fetch({
1484
+ chainId,
1485
+ engine: 'clickhouse',
1486
+ query: `
1487
+ SELECT
1488
+ count(*) AS total,
1489
+ countIf(
1490
+ "to" = '${Addresses.stablecoinDex}'
1491
+ OR "from" = '${Addresses.stablecoinDex}'
1492
+ OR "to" = '${Addresses.feeManager}'
1493
+ ) AS suppressed
1494
+ FROM (
1495
+ SELECT tx_hash, log_idx, "from", "to"
1496
+ FROM token_transfers FINAL
1497
+ WHERE ${materializedFilters.join(' AND ')}
1498
+ LIMIT ${Schema.countCap}
1499
+ ) AS capped
1500
+ ` as string,
1501
+ })
1502
+ const total = Value.toNumber(result.rows[0]?.['total']) ?? 0
1503
+ const suppressed = Value.toNumber(result.rows[0]?.['suppressed']) ?? 0
1504
+ if (total >= Schema.countCap) {
1505
+ return {
1506
+ totalCount: Math.max(0, total - suppressed),
1507
+ totalCountCapped: true,
1508
+ }
1509
+ }
1510
+ if (suppressed === 0) return { totalCount: total, totalCountCapped: false }
1511
+ }
1512
+
1513
+ if (address !== undefined) {
1514
+ const materializedFilters = [
1515
+ ...(options.token === undefined
1516
+ ? [`token >= '${tip20AddressRange[0]}'`, `token < '${tip20AddressRange[1]}'`]
1517
+ : [`token = '${options.token}'`]),
1518
+ ...(options.sender !== undefined ? [`"from" = '${options.sender}'`] : []),
1519
+ ...(options.recipient !== undefined ? [`"to" = '${options.recipient}'`] : []),
1520
+ ...(options.fromBlock !== undefined ? [`block_num >= ${options.fromBlock}`] : []),
1521
+ ...(options.toBlock !== undefined ? [`block_num <= ${options.toBlock}`] : []),
1522
+ ...(options.fromTimestamp !== undefined
1523
+ ? [`block_timestamp >= '${options.fromTimestamp}'`]
1524
+ : []),
1525
+ ...(options.toTimestamp !== undefined
1526
+ ? [`block_timestamp <= '${options.toTimestamp}'`]
1527
+ : []),
1528
+ ]
1529
+ const sentFilters = [`"from" = '${address}'`, ...materializedFilters]
1530
+ const receivedFilters = [
1531
+ `"to" = '${address}'`,
1532
+ `"from" != '${address}'`,
1533
+ ...materializedFilters,
1534
+ ]
1535
+ const aggregate = await tidx.fetch({
1536
+ chainId,
1537
+ engine: 'clickhouse',
1538
+ query: `
1539
+ SELECT
1540
+ 'sent' AS side,
1541
+ count(*) AS total,
1542
+ countIf(
1543
+ "to" = '${Addresses.stablecoinDex}'
1544
+ OR "from" = '${Addresses.stablecoinDex}'
1545
+ OR "to" = '${Addresses.feeManager}'
1546
+ ) AS suppressed
1547
+ FROM (
1548
+ SELECT "from", "to"
1549
+ FROM token_transfers
1550
+ WHERE ${sentFilters.join(' AND ')}
1551
+ LIMIT ${Schema.countCap}
1552
+ ) AS sent
1553
+ UNION ALL
1554
+ SELECT
1555
+ 'received' AS side,
1556
+ count(*) AS total,
1557
+ countIf(
1558
+ "to" = '${Addresses.stablecoinDex}'
1559
+ OR "from" = '${Addresses.stablecoinDex}'
1560
+ OR "to" = '${Addresses.feeManager}'
1561
+ ) AS suppressed
1562
+ FROM (
1563
+ SELECT "from", "to"
1564
+ FROM token_transfers
1565
+ WHERE ${receivedFilters.join(' AND ')}
1566
+ LIMIT ${Schema.countCap}
1567
+ ) AS received
1568
+ ` as string,
1569
+ })
1570
+ const sent = aggregate.rows.find((row) => Value.toText(row['side']) === 'sent')
1571
+ const received = aggregate.rows.find((row) => Value.toText(row['side']) === 'received')
1572
+ const sentTotal = Value.toNumber(sent?.['total']) ?? 0
1573
+ const receivedTotal = Value.toNumber(received?.['total']) ?? 0
1574
+ const total = sentTotal + receivedTotal
1575
+ const suppressed =
1576
+ (Value.toNumber(sent?.['suppressed']) ?? 0) +
1577
+ (Value.toNumber(received?.['suppressed']) ?? 0)
1578
+ const capped =
1579
+ sentTotal >= Schema.countCap || receivedTotal >= Schema.countCap || total >= Schema.countCap
1580
+ if (capped)
1581
+ return {
1582
+ totalCount: Math.min(Math.max(0, total - suppressed), Schema.countCap),
1583
+ totalCountCapped: true,
1584
+ }
1585
+ if (suppressed === 0) return { totalCount: total, totalCountCapped: false }
1586
+
1587
+ // A below-cap aggregate proves these unsorted scans are complete, so the
1588
+ // exact bundle classifier does not need a global sort.
1589
+ const select = 'tx_hash, block_num, log_idx, "from", "to", token AS address, amount AS value'
1590
+ const fetchRows = async (sideFilters: readonly string[]) => {
1591
+ const result = await tidx.fetch({
1592
+ chainId,
1593
+ engine: 'clickhouse',
1594
+ query: `
1595
+ SELECT ${select}
1596
+ FROM token_transfers
1597
+ WHERE ${sideFilters.join(' AND ')}
1598
+ LIMIT ${Schema.countCap}
1599
+ ` as string,
1600
+ })
1601
+ return result.rows
1602
+ }
1603
+ const [sentRows, receivedRows] = await Promise.all([
1604
+ fetchRows(sentFilters),
1605
+ fetchRows(receivedFilters),
1606
+ ])
1607
+ const rows = [...sentRows, ...receivedRows]
1608
+ const transactionHashes = [...legsByTransaction(rows)]
1609
+ .filter(([, legs]) => legs.some(isBundleLeg))
1610
+ .map(([transactionHash]) => transactionHash)
1611
+ const transactionHashSet = new Set(transactionHashes)
1612
+ const candidateRows = rows.filter((row) => {
1613
+ const transactionHash = Value.toText(row['tx_hash'])
1614
+ return (
1615
+ transactionHash !== undefined && transactionHashSet.has(transactionHash.toLowerCase())
1616
+ )
1617
+ })
1618
+ const blocks = candidateRows.flatMap((row) => {
1619
+ const block = Value.toNumber(row['block_num'])
1620
+ return block === undefined ? [] : [block]
1621
+ })
1622
+ if (blocks.length === 0 || transactionHashes.length === 0)
1623
+ return foldCount(rows, { capped: false })
1624
+ // Address predicates omit sibling bundle legs. Load each candidate
1625
+ // transaction completely, then suppress only address-matched rows.
1626
+ const classification = await tidx.fetch({
1627
+ chainId,
1628
+ engine: 'clickhouse',
1629
+ query: `
1630
+ SELECT tx_hash, block_num, log_idx, "from", "to", token AS address, amount AS value
1631
+ FROM token_transfers
1632
+ WHERE block_num BETWEEN ${Math.min(...blocks)} AND ${Math.max(...blocks)}
1633
+ AND tx_hash IN (${transactionHashes.map((hash) => `'${hash}'`).join(', ')})
1634
+ ` as string,
1635
+ })
1636
+ return foldCount(rows, { capped: false, classificationRows: classification.rows })
1637
+ }
1638
+
1036
1639
  // Only one-sided `"from"`/`"to"` equality shapes hit the planner's
1037
1640
  // deterministic top-N kill, so only they take the capped fallback.
1038
- const sideFiltered =
1039
- address !== undefined || options.sender !== undefined || options.recipient !== undefined
1040
- const fetchRows = async (side?: 'from' | 'to') => {
1041
- const sideFilters =
1042
- side !== undefined && address !== undefined
1043
- ? [`"${side}" = '${address}'`, ...filters]
1044
- : filters
1045
- const sideWhere = sideFilters.length > 0 ? `WHERE ${sideFilters.join(' AND ')}` : ''
1641
+ const sideFiltered = options.sender !== undefined || options.recipient !== undefined
1642
+ const fetchRows = async () => {
1643
+ const where = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''
1046
1644
  const select = `tx_hash, block_num, log_idx, "from", "to", ${tokenScoped ? 'token AS address, amount AS value' : 'address, value'}`
1047
1645
  const table = tokenScoped ? 'token_transfers' : 'Transfer'
1048
1646
  try {
@@ -1052,12 +1650,12 @@ export async function countScan(
1052
1650
  query: `
1053
1651
  SELECT ${select}
1054
1652
  FROM ${table}
1055
- ${sideWhere}
1653
+ ${where}
1056
1654
  ORDER BY block_num, log_idx
1057
1655
  LIMIT ${Schema.countCap}
1058
1656
  ` as string,
1059
1657
  })
1060
- return { rows: result.rows, sample: false }
1658
+ return { capped: false, rows: result.rows, sample: false }
1061
1659
  } catch (error) {
1062
1660
  // Same planner kill as the page's side scan; the folded count is
1063
1661
  // already capped, so a plain capped scan keeps its semantics.
@@ -1068,17 +1666,14 @@ export async function countScan(
1068
1666
  query: `
1069
1667
  SELECT ${select}
1070
1668
  FROM ${table}
1071
- ${sideWhere}
1669
+ ${where}
1072
1670
  LIMIT ${Schema.countCap}
1073
1671
  ` as string,
1074
1672
  })
1075
- return { rows: result.rows, sample: true }
1673
+ return { capped: result.rows.length >= Schema.countCap, rows: result.rows, sample: true }
1076
1674
  }
1077
1675
  }
1078
- const fetched =
1079
- address !== undefined
1080
- ? await Promise.all([fetchRows('from'), fetchRows('to')])
1081
- : [await fetchRows()]
1676
+ const fetched = [await fetchRows()]
1082
1677
  const { capped, rows } = collate(fetched, { limit: Schema.countCap, offset: 0, order: 'asc' })
1083
1678
  return foldCount(rows, { capped })
1084
1679
  }
@@ -1139,15 +1734,24 @@ export function foldCount(
1139
1734
  rows: readonly Record<string, unknown>[],
1140
1735
  options: foldCount.Options,
1141
1736
  ): count.Result {
1142
- const { capped } = options
1737
+ const { capped, classificationRows = rows } = options
1738
+ const rowKeys = new Set<string>()
1739
+ for (const row of rows) {
1740
+ const transactionHash = Value.toText(row['tx_hash'])
1741
+ const logIndex = Value.toNumber(row['log_idx'])
1742
+ if (transactionHash && logIndex !== undefined) rowKeys.add(eventKey(transactionHash, logIndex))
1743
+ }
1143
1744
  let suppressed = 0
1144
- for (const legs of legsByTransaction(rows).values()) {
1745
+ for (const [transactionHash, legs] of legsByTransaction(capped ? rows : classificationRows)) {
1145
1746
  if (capped) {
1146
1747
  suppressed += legs.filter(isBundleLeg).length
1147
1748
  continue
1148
1749
  }
1149
1750
  const bundle = classifyCrossToken(legs)
1150
- if (bundle) suppressed += bundle.suppressLogIndexes.length
1751
+ if (bundle)
1752
+ suppressed += bundle.suppressLogIndexes.filter((logIndex) =>
1753
+ rowKeys.has(eventKey(transactionHash, logIndex)),
1754
+ ).length
1151
1755
  }
1152
1756
  return {
1153
1757
  totalCount: rows.length - suppressed,
@@ -1160,6 +1764,8 @@ export declare namespace foldCount {
1160
1764
  type Options = {
1161
1765
  /** Whether the rows include a cap-truncated fallback sample. */
1162
1766
  capped: boolean
1767
+ /** Complete transaction legs used to classify the filtered rows. */
1768
+ classificationRows?: readonly Record<string, unknown>[] | undefined
1163
1769
  }
1164
1770
  }
1165
1771
 
@@ -1289,12 +1895,13 @@ type CrossTokenBundle = {
1289
1895
  * transfer and is left untouched.
1290
1896
  */
1291
1897
  function classifyCrossToken(legs: readonly Leg[]): CrossTokenBundle | undefined {
1292
- const toDex = legs.find((leg) => Address.isEqual(leg.to, Addresses.stablecoinDex))
1293
- const fromDex = legs.find((leg) => Address.isEqual(leg.from, Addresses.stablecoinDex))
1898
+ const ordered = [...legs].sort((a, b) => a.logIndex - b.logIndex)
1899
+ const toDex = ordered.find((leg) => Address.isEqual(leg.to, Addresses.stablecoinDex))
1900
+ const fromDex = ordered.find((leg) => Address.isEqual(leg.from, Addresses.stablecoinDex))
1294
1901
  if (!toDex || !fromDex) return undefined
1295
1902
  const swapper = fromDex.to
1296
1903
  const destTokenAddress = fromDex.address
1297
- const delivery = legs.find(
1904
+ const delivery = ordered.find(
1298
1905
  (leg) =>
1299
1906
  Address.isEqual(leg.address, destTokenAddress) &&
1300
1907
  Address.isEqual(leg.from, swapper) &&
@@ -1304,7 +1911,7 @@ function classifyCrossToken(legs: readonly Leg[]): CrossTokenBundle | undefined
1304
1911
  )
1305
1912
  // Require a token change so plain same-token DEX activity is not folded.
1306
1913
  if (!delivery || Address.isEqual(toDex.address, delivery.address)) return undefined
1307
- const suppressLogIndexes = legs
1914
+ const suppressLogIndexes = ordered
1308
1915
  .filter((leg) => leg.logIndex !== delivery.logIndex && isBundleLeg(leg))
1309
1916
  .map((leg) => leg.logIndex)
1310
1917
  return {