tapimo 0.10.1 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/dist/App.d.ts +1776 -1543
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/Webhooks.d.ts +1 -1
  4. package/dist/Webhooks.d.ts.map +1 -1
  5. package/dist/Webhooks.js +1 -1
  6. package/dist/Webhooks.js.map +1 -1
  7. package/dist/admin/apps/api-keys.d.ts +4 -4
  8. package/dist/analytics/Analytics.d.ts.map +1 -1
  9. package/dist/analytics/Analytics.js +206 -0
  10. package/dist/analytics/Analytics.js.map +1 -1
  11. package/dist/analytics/tables/requestEvents.d.ts +4 -0
  12. package/dist/analytics/tables/requestEvents.d.ts.map +1 -1
  13. package/dist/analytics/tables/requestEvents.js +2 -0
  14. package/dist/analytics/tables/requestEvents.js.map +1 -1
  15. package/dist/apps/data/App.d.ts.map +1 -1
  16. package/dist/apps/data/App.js +2 -0
  17. package/dist/apps/data/App.js.map +1 -1
  18. package/dist/apps/data/routes/addresses.d.ts +340 -340
  19. package/dist/apps/data/routes/balances.d.ts.map +1 -1
  20. package/dist/apps/data/routes/balances.js +2 -6
  21. package/dist/apps/data/routes/balances.js.map +1 -1
  22. package/dist/apps/data/routes/coingecko.d.ts +176 -176
  23. package/dist/apps/data/routes/earn.d.ts.map +1 -1
  24. package/dist/apps/data/routes/earn.js +63 -34
  25. package/dist/apps/data/routes/earn.js.map +1 -1
  26. package/dist/apps/data/routes/exchanges.d.ts +4 -0
  27. package/dist/apps/data/routes/exchanges.d.ts.map +1 -1
  28. package/dist/apps/data/routes/exchanges.js +115 -77
  29. package/dist/apps/data/routes/exchanges.js.map +1 -1
  30. package/dist/apps/data/routes/indexer.d.ts +34 -34
  31. package/dist/apps/data/routes/receipts.d.ts +4 -3
  32. package/dist/apps/data/routes/receipts.d.ts.map +1 -1
  33. package/dist/apps/data/routes/receipts.js +15 -14
  34. package/dist/apps/data/routes/receipts.js.map +1 -1
  35. package/dist/apps/data/routes/tokens.d.ts +1 -1
  36. package/dist/apps/data/routes/tokens.d.ts.map +1 -1
  37. package/dist/apps/data/routes/tokens.js +4 -3
  38. package/dist/apps/data/routes/tokens.js.map +1 -1
  39. package/dist/apps/data/routes/transactions.d.ts +281 -281
  40. package/dist/apps/data/routes/transfers.d.ts.map +1 -1
  41. package/dist/apps/data/routes/transfers.js +25 -7
  42. package/dist/apps/data/routes/transfers.js.map +1 -1
  43. package/dist/apps/data/routes/webhooks.d.ts +380 -364
  44. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  45. package/dist/apps/data/routes/webhooks.js +16 -9
  46. package/dist/apps/data/routes/webhooks.js.map +1 -1
  47. package/dist/apps/data/routes/zones.d.ts +31 -31
  48. package/dist/apps/funding/App.d.ts +233 -0
  49. package/dist/apps/funding/App.d.ts.map +1 -1
  50. package/dist/apps/funding/routes/deposit-addresses.d.ts +330 -0
  51. package/dist/apps/funding/routes/deposit-addresses.d.ts.map +1 -1
  52. package/dist/apps/funding/routes/deposit-addresses.js +207 -11
  53. package/dist/apps/funding/routes/deposit-addresses.js.map +1 -1
  54. package/dist/apps/funding/routes/providers.d.ts +13 -13
  55. package/dist/apps/management/routes/faucet.d.ts +32 -32
  56. package/dist/apps/management/routes/members.d.ts +79 -79
  57. package/dist/apps/management/routes/usage.d.ts +80 -80
  58. package/dist/db/Db.d.ts.map +1 -1
  59. package/dist/db/Db.js +32 -0
  60. package/dist/db/Db.js.map +1 -1
  61. package/dist/db/tables/fundingDepositAddresses.d.ts +20 -0
  62. package/dist/db/tables/fundingDepositAddresses.d.ts.map +1 -1
  63. package/dist/db/tables/fundingDepositAddresses.js +17 -0
  64. package/dist/db/tables/fundingDepositAddresses.js.map +1 -1
  65. package/dist/db/tables/fundingDeposits.d.ts +41 -0
  66. package/dist/db/tables/fundingDeposits.d.ts.map +1 -1
  67. package/dist/db/tables/fundingDeposits.js +69 -0
  68. package/dist/db/tables/fundingDeposits.js.map +1 -1
  69. package/dist/internal/Auth.d.ts +0 -26
  70. package/dist/internal/Auth.d.ts.map +1 -1
  71. package/dist/internal/Auth.js +0 -33
  72. package/dist/internal/Auth.js.map +1 -1
  73. package/dist/internal/Credential.d.ts +27 -0
  74. package/dist/internal/Credential.d.ts.map +1 -0
  75. package/dist/internal/Credential.js +34 -0
  76. package/dist/internal/Credential.js.map +1 -0
  77. package/dist/internal/EarnRates.js +11 -7
  78. package/dist/internal/EarnRates.js.map +1 -1
  79. package/dist/internal/Log.d.ts +8 -0
  80. package/dist/internal/Log.d.ts.map +1 -1
  81. package/dist/internal/Log.js +4 -0
  82. package/dist/internal/Log.js.map +1 -1
  83. package/dist/internal/MetricSink.d.ts +24 -0
  84. package/dist/internal/MetricSink.d.ts.map +1 -1
  85. package/dist/internal/MetricSink.js +25 -0
  86. package/dist/internal/MetricSink.js.map +1 -1
  87. package/dist/internal/Tidx.d.ts +4 -3
  88. package/dist/internal/Tidx.d.ts.map +1 -1
  89. package/dist/internal/Tidx.js +2 -2
  90. package/dist/internal/Tidx.js.map +1 -1
  91. package/dist/internal/Viem.d.ts +4 -3
  92. package/dist/internal/Viem.d.ts.map +1 -1
  93. package/dist/internal/Viem.js +3 -2
  94. package/dist/internal/Viem.js.map +1 -1
  95. package/dist/internal/Webhooks.d.ts +2 -0
  96. package/dist/internal/Webhooks.d.ts.map +1 -1
  97. package/dist/internal/Webhooks.js +10 -3
  98. package/dist/internal/Webhooks.js.map +1 -1
  99. package/dist/internal/index.d.ts +1 -0
  100. package/dist/internal/index.d.ts.map +1 -1
  101. package/dist/internal/index.js +1 -0
  102. package/dist/internal/index.js.map +1 -1
  103. package/package.json +1 -1
  104. package/src/App.test.ts +30 -0
  105. package/src/Client.test-d.ts +33 -1
  106. package/src/Webhooks.ts +1 -1
  107. package/src/analytics/Analytics.test.ts +34 -0
  108. package/src/analytics/Analytics.ts +206 -0
  109. package/src/analytics/tables/requestEvents.ts +6 -0
  110. package/src/apps/composition.test.ts +1 -0
  111. package/src/apps/data/App.ts +2 -0
  112. package/src/apps/data/routes/balances.ts +2 -6
  113. package/src/apps/data/routes/earn.ts +64 -34
  114. package/src/apps/data/routes/exchanges.test.ts +8 -3
  115. package/src/apps/data/routes/exchanges.ts +134 -82
  116. package/src/apps/data/routes/receipts.ts +16 -14
  117. package/src/apps/data/routes/tokens.ts +3 -2
  118. package/src/apps/data/routes/transactions.bench.ts +8 -0
  119. package/src/apps/data/routes/transfers.ts +36 -10
  120. package/src/apps/data/routes/webhooks.ts +31 -10
  121. package/src/apps/funding/routes/deposit-addresses.test.ts +216 -2
  122. package/src/apps/funding/routes/deposit-addresses.ts +254 -18
  123. package/src/apps/funding/routes/transfers.test.ts +17 -2
  124. package/src/db/Db.ts +36 -0
  125. package/src/db/tables/fundingDepositAddresses.test.ts +28 -0
  126. package/src/db/tables/fundingDepositAddresses.ts +39 -0
  127. package/src/db/tables/fundingDeposits.test.ts +82 -0
  128. package/src/db/tables/fundingDeposits.ts +130 -0
  129. package/src/internal/Auth.test.ts +0 -48
  130. package/src/internal/Auth.ts +0 -59
  131. package/src/internal/Credential.test.ts +51 -0
  132. package/src/internal/Credential.ts +58 -0
  133. package/src/internal/EarnRates.ts +13 -7
  134. package/src/internal/Log.test.ts +3 -1
  135. package/src/internal/Log.ts +14 -0
  136. package/src/internal/MetricSink.test.ts +50 -1
  137. package/src/internal/MetricSink.ts +52 -0
  138. package/src/internal/Tidx.ts +5 -4
  139. package/src/internal/Viem.test.ts +4 -0
  140. package/src/internal/Viem.ts +6 -4
  141. package/src/internal/Webhooks.test.ts +9 -0
  142. package/src/internal/Webhooks.ts +10 -3
  143. package/src/internal/index.test.ts +1 -0
  144. package/src/internal/index.ts +1 -0
@@ -375,9 +375,7 @@ export function addresses(options: addresses.Options = {}) {
375
375
  ),
376
376
  )
377
377
  const client = c.get('getClient')(chainId)
378
- const caller = c.get('zones').has(chainId)
379
- ? (await client.zone.getZoneInfo()).sequencers[0]
380
- : undefined
378
+ const caller = c.get('zones').has(chainId) ? address : undefined
381
379
  const [balance, feeTokens, snapshot, resolvedToken] = await Promise.all([
382
380
  Timing.time(c, 'balance_rpc', async () => {
383
381
  const call = Actions.token.getBalance.call(client, {
@@ -516,9 +514,7 @@ async function getAddressBalances(
516
514
  const correctedPromise = holdingsPromise.then(async (holdings) => {
517
515
  if (holdings.length === 0) return []
518
516
  const client = c.get('getClient')(chainId)
519
- const caller = c.get('zones').has(chainId)
520
- ? (await client.zone.getZoneInfo()).sequencers[0]
521
- : undefined
517
+ const caller = c.get('zones').has(chainId) ? address : undefined
522
518
  return Timing.time(c, 'balances_rpc', async () => {
523
519
  // Concurrent reads enter viem's shared batch window and become one
524
520
  // deployless multicall. A failed read fails the request rather than serving
@@ -2034,6 +2034,8 @@ export async function listVaults(c: Context<App.Environment>, options: listVault
2034
2034
  core_EarnVaults.list(db, { chainId: options.chainId }),
2035
2035
  VerifiedTokens.snapshot(c, options.chainId),
2036
2036
  ])
2037
+ const include = vaultInclude(options)
2038
+ const apyByVault = new Map<string, EarnRates.Apy | null>()
2037
2039
  const registry = new Map(records.map((record) => [record.vaultAddress.toLowerCase(), record]))
2038
2040
  const decoded = options.cursor ? Cursor.decode(options.cursor, ['int', 'int']) : undefined
2039
2041
  let position = decoded
@@ -2062,22 +2064,44 @@ export async function listVaults(c: Context<App.Environment>, options: listVault
2062
2064
  }
2063
2065
  scanned += page.items.length
2064
2066
 
2065
- const discovered = await Promise.all(
2066
- page.items.map(async (item) => {
2067
- const record = registry.get(item.vaultAddress.toLowerCase())
2068
- try {
2069
- const discovery = await resolveVaultDiscovery(c, {
2067
+ const apyVaults =
2068
+ apyByVault.size === 0 &&
2069
+ include.includes('apy') &&
2070
+ options.capability.length === 0 &&
2071
+ options['engine.type'] === undefined
2072
+ ? page.items
2073
+ .slice(0, options.limit - matches.length)
2074
+ .map(({ vaultAddress }) => vaultAddress)
2075
+ : []
2076
+ // Rate boundaries and historical quotes depend only on candidate addresses,
2077
+ // so the unfiltered page can resolve them alongside live vault discovery.
2078
+ const [discovered, prefetchedApy] = await Promise.all([
2079
+ Promise.all(
2080
+ page.items.map(async (item) => {
2081
+ const record = registry.get(item.vaultAddress.toLowerCase())
2082
+ try {
2083
+ const discovery = await resolveVaultDiscovery(c, {
2084
+ chainId: options.chainId,
2085
+ include: discoveryInclude(options),
2086
+ vaultAddress: item.vaultAddress,
2087
+ })
2088
+ return { discovery, position: item.position, record }
2089
+ } catch (cause) {
2090
+ if (!record && isIncompatible(cause)) return undefined
2091
+ throw cause
2092
+ }
2093
+ }),
2094
+ ),
2095
+ apyVaults.length > 0
2096
+ ? resolvePageApy(c, {
2070
2097
  chainId: options.chainId,
2071
- include: discoveryInclude(options),
2072
- vaultAddress: item.vaultAddress,
2098
+ include,
2099
+ vaults: apyVaults,
2100
+ window: options['apy.window'],
2073
2101
  })
2074
- return { discovery, position: item.position, record }
2075
- } catch (cause) {
2076
- if (!record && isIncompatible(cause)) return undefined
2077
- throw cause
2078
- }
2079
- }),
2080
- )
2102
+ : undefined,
2103
+ ])
2104
+ for (const [vault, apy] of prefetchedApy ?? []) apyByVault.set(vault, apy)
2081
2105
  for (const match of discovered) {
2082
2106
  if (!match || !matchesVaultFilters(match.discovery, options)) continue
2083
2107
  matches.push(match)
@@ -2089,17 +2113,25 @@ export async function listVaults(c: Context<App.Environment>, options: listVault
2089
2113
 
2090
2114
  const page = matches.slice(0, options.limit)
2091
2115
  const discoveries = page.map(({ discovery }) => discovery)
2092
- const include = vaultInclude(options)
2093
- const [apy, rates, tokens] = await Promise.all([
2094
- resolvePageApy(c, {
2095
- chainId: options.chainId,
2096
- discoveries,
2097
- include,
2098
- window: options['apy.window'],
2099
- }),
2116
+ const missingApyVaults = include.includes('apy')
2117
+ ? discoveries.flatMap(({ vaultAddress }) =>
2118
+ apyByVault.has(vaultAddress.toLowerCase()) ? [] : [vaultAddress],
2119
+ )
2120
+ : []
2121
+ const [missingApy, rates, tokens] = await Promise.all([
2122
+ missingApyVaults.length > 0
2123
+ ? resolvePageApy(c, {
2124
+ chainId: options.chainId,
2125
+ include,
2126
+ vaults: missingApyVaults,
2127
+ window: options['apy.window'],
2128
+ })
2129
+ : undefined,
2100
2130
  resolvePageRates(c, { discoveries, include, oracle: options.oracle, snapshot }),
2101
2131
  resolvePageTokens(c, { chainId: options.chainId, discoveries, include, snapshot }),
2102
2132
  ])
2133
+ for (const [vault, apy] of missingApy ?? []) apyByVault.set(vault, apy)
2134
+ const apy = include.includes('apy') ? apyByVault : undefined
2103
2135
  const data = page.map((match) =>
2104
2136
  resolveVault(c, { ...match, apy, include, rates, snapshot, tokens }),
2105
2137
  )
@@ -2181,8 +2213,8 @@ export async function listVerifiedVaults(
2181
2213
  const [apy, rates, tokens] = await Promise.all([
2182
2214
  resolvePageApy(c, {
2183
2215
  chainId: options.chainId,
2184
- discoveries,
2185
2216
  include,
2217
+ vaults: discoveries.map(({ vaultAddress }) => vaultAddress),
2186
2218
  window: options['apy.window'],
2187
2219
  }),
2188
2220
  resolvePageRates(c, { discoveries, include, oracle: options.oracle, snapshot }),
@@ -2503,8 +2535,8 @@ export async function getVault(c: Context<App.Environment>, options: getVault.Op
2503
2535
  const [apy, rates, tokens] = await Promise.all([
2504
2536
  resolvePageApy(c, {
2505
2537
  chainId: options.chainId,
2506
- discoveries,
2507
2538
  include,
2539
+ vaults: discoveries.map(({ vaultAddress }) => vaultAddress),
2508
2540
  window: options['apy.window'],
2509
2541
  }),
2510
2542
  resolvePageRates(c, { discoveries, include, oracle: options.oracle, snapshot }),
@@ -2616,7 +2648,7 @@ function resolvePageApy(c: Context<App.Environment>, options: resolvePageApy.Opt
2616
2648
  getClient: c.get('getClient'),
2617
2649
  getTidx: c.get('getTidx'),
2618
2650
  store: c.get('store'),
2619
- vaults: options.discoveries.map(({ vaultAddress }) => vaultAddress),
2651
+ vaults: options.vaults,
2620
2652
  window: options.window,
2621
2653
  }),
2622
2654
  )
@@ -2625,8 +2657,8 @@ function resolvePageApy(c: Context<App.Environment>, options: resolvePageApy.Opt
2625
2657
  declare namespace resolvePageApy {
2626
2658
  type Options = {
2627
2659
  chainId: z.output<typeof Schema.ChainId>
2628
- discoveries: readonly EarnVaults.Discovery[]
2629
2660
  include: z.output<typeof schema.getEarnVault.Query>['include']
2661
+ vaults: readonly z.output<typeof Schema.Address>[]
2630
2662
  window: z.output<typeof schema.getEarnVault.Query>['apy.window']
2631
2663
  }
2632
2664
  }
@@ -2837,7 +2869,7 @@ async function queryDeployments(
2837
2869
  c: Context<App.Environment>,
2838
2870
  options: queryDeployments.Options,
2839
2871
  ): Promise<queryDeployments.Result> {
2840
- const filters = ['deployment_rank = 1']
2872
+ const filters: string[] = []
2841
2873
  if (options.asset) filters.push(`lower(asset) = '${options.asset.toLowerCase()}'`)
2842
2874
  if (options.cursor)
2843
2875
  filters.push(
@@ -2855,7 +2887,7 @@ async function queryDeployments(
2855
2887
  ]),
2856
2888
  )
2857
2889
 
2858
- // ClickHouse supports ordering and windowing over decoded event CTEs.
2890
+ // Deduplicate before outer filters so every vault is filtered by its latest deployment.
2859
2891
  const result = await c
2860
2892
  .get('getTidx')(options.chainId)
2861
2893
  .fetch({
@@ -2868,14 +2900,12 @@ async function queryDeployments(
2868
2900
  "earnVault",
2869
2901
  asset,
2870
2902
  block_num,
2871
- log_idx,
2872
- row_number() OVER (
2873
- PARTITION BY lower("earnVault")
2874
- ORDER BY block_num DESC, log_idx DESC
2875
- ) AS deployment_rank
2903
+ log_idx
2876
2904
  FROM EarnStackDeployed
2905
+ ORDER BY block_num DESC, log_idx DESC
2906
+ LIMIT 1 BY lower("earnVault")
2877
2907
  )
2878
- WHERE ${filters.join(' AND ')}
2908
+ ${filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''}
2879
2909
  ORDER BY block_num DESC, log_idx DESC
2880
2910
  LIMIT ${options.limit + 1}
2881
2911
  ` as string,
@@ -531,7 +531,7 @@ describe('GET /exchange/orders?base=… (pair-scoped orders)', () => {
531
531
  })
532
532
 
533
533
  describe('GET /exchange/pairs/:base/depth', () => {
534
- test('returns orderbook depth for a real pair', async () => {
534
+ test('returns orderbook depth when token metadata is requested', async () => {
535
535
  const client = TestApp.client()
536
536
  const pairsResponse = await client.v1.exchange.pairs.$get(
537
537
  { query: { limit: '5' } },
@@ -547,21 +547,25 @@ describe('GET /exchange/pairs/:base/depth', () => {
547
547
  // throttles the multicall response.
548
548
  let body: z.output<typeof Exchanges.schema.getPairDepth.Response> | null = null
549
549
  let pair: (typeof pairs.data)[number] | null = null
550
+ let timing: string | null = null
550
551
  for (const candidate of pairs.data) {
551
552
  const response = await client.v1.exchange.pairs[':base'].depth.$get(
552
- { param: { base: candidate.base.address }, query: {} },
553
+ { param: { base: candidate.base.address }, query: { include: 'tokens' } },
553
554
  TestApp.auth,
554
555
  )
555
556
  if (response.status === 502) return
556
557
  expect(response.status).toBe(200)
557
558
  body = await TestApp.json(response, Exchanges.schema.getPairDepth.Response)
558
559
  pair = candidate
560
+ timing = response.headers.get('server-timing')
559
561
  if (body.bids.length > 0 || body.asks.length > 0) break
560
562
  }
561
563
  if (!body || !pair) return
562
564
 
563
565
  expect(body.base.address).toBe(pair.base.address)
564
566
  expect(body.quote.address).toBe(pair.quote.address)
567
+ expect(timing).toContain('pair_lookup;dur=')
568
+ expect(timing).not.toContain('pair_index;dur=')
565
569
 
566
570
  // Asks are emitted lowest-tick first (best ask outward); bids are
567
571
  // emitted highest-tick first (best bid outward). Per-row `cumulativeSize`
@@ -689,9 +693,9 @@ describe('GET /exchange/swaps', () => {
689
693
  const response = await client.v1.exchange.swaps.$get({ query: { limit: '10' } }, TestApp.auth)
690
694
  expect(response.status).toBe(200)
691
695
  const body = await TestApp.json(response, Exchanges.schema.getSwaps.Response)
696
+ expect(body.data.length).toBeGreaterThan(0)
692
697
  expect(body.data.length).toBeLessThanOrEqual(10)
693
698
  expect(response.headers.get('server-timing')).not.toContain('valuation_rates;dur=')
694
- if (body.data.length === 0) return
695
699
 
696
700
  for (const swap of body.data) {
697
701
  expect(swap.taker).toMatch(/^0x[0-9a-f]{40}$/)
@@ -827,6 +831,7 @@ describe('GET /exchange/swaps', () => {
827
831
  TestApp.auth,
828
832
  )
829
833
  expect(response.status).toBe(200)
834
+ expect(response.headers.get('server-timing')).not.toContain('token_logo_uri;dur=')
830
835
  const body = await TestApp.json(response, Exchanges.schema.getSwaps.Response)
831
836
  if (body.data.length === 0) return
832
837
 
@@ -2209,20 +2209,22 @@ export function exchanges(options: exchanges.Options = {}) {
2209
2209
  transactionHash: options.transactionHash,
2210
2210
  })
2211
2211
 
2212
- // `route` spans every swap and fill side, so one lookup resolves each
2213
- // token once for required RPC metadata and requested curated fields.
2214
- const tokensByAddress = await Tokens.resolveTokens(c, {
2215
- addresses: page.data.flatMap((swap) => swap.route),
2216
- chainId,
2217
- include: options.include,
2218
- })
2219
2212
  // Value amounts via their curated display currency; snapshot or rate
2220
2213
  // failures degrade the values to `null`, never the feed.
2221
2214
  const denomination = options['valuation.currency']
2222
- const snapshot =
2215
+ // Mode, verified-token, and RPC metadata reads depend only on the
2216
+ // assembled page, so overlap them before enriching its rows.
2217
+ const [, snapshot, tokensByAddress] = await Promise.all([
2218
+ resolveSwapModes(c, { chainId, swaps: page.data }),
2223
2219
  denomination !== undefined && page.data.length > 0
2224
- ? await VerifiedTokens.snapshot(c, chainId).catch(() => undefined)
2225
- : undefined
2220
+ ? VerifiedTokens.snapshot(c, chainId).catch(() => undefined)
2221
+ : undefined,
2222
+ Tokens.resolveTokens(c, {
2223
+ addresses: page.data.flatMap((swap) => swap.route),
2224
+ chainId,
2225
+ include: options.include,
2226
+ }),
2227
+ ])
2226
2228
  const rates = denomination
2227
2229
  ? await Valuation.ratesFor(c, {
2228
2230
  currencies: snapshot
@@ -3001,27 +3003,43 @@ export function exchanges(options: exchanges.Options = {}) {
3001
3003
  const chainId = options.chainId ?? c.get('chainId')
3002
3004
 
3003
3005
  try {
3004
- const pairs = await getPairIndex(c, { chainId })
3005
- const target = pairs.get(baseAddress)
3006
- if (!target) return pairNotFoundError(c, { base: baseAddress, pairIndex: pairs })
3006
+ // Pair definitions are immutable after creation, so cache successful
3007
+ // point lookups without making unknown pairs stale.
3008
+ const target = await Store.memoize(
3009
+ async () =>
3010
+ (await getPairsByBase(c, { bases: [baseAddress], chainId })).get(baseAddress),
3011
+ {
3012
+ key: `exchange:v1:${chainId}:pair-by-base:${baseAddress}:v1`,
3013
+ store: c.get('store'),
3014
+ ttl: Ttl.minutes(5),
3015
+ },
3016
+ )
3017
+ if (!target)
3018
+ return pairNotFoundError(c, {
3019
+ base: baseAddress,
3020
+ pairIndex: await getPairIndex(c, { chainId }),
3021
+ })
3007
3022
 
3008
- const { asks, bids } = await getPairDepth(c, {
3009
- chainId,
3010
- levels: options.levels,
3011
- pair: target,
3012
- })
3023
+ // Token metadata is opt-in and resolves once per pair onto the top-level
3024
+ // `base`/`quote` slots. Run it alongside the independent depth RPC scan.
3025
+ const [{ asks, bids }, tokensByAddress] = await Promise.all([
3026
+ getPairDepth(c, {
3027
+ chainId,
3028
+ levels: options.levels,
3029
+ pair: target,
3030
+ }),
3031
+ options.include.includes('tokens')
3032
+ ? Tokens.resolveTokens(c, {
3033
+ addresses: [target.base, target.quote],
3034
+ chainId,
3035
+ include: tokenFields,
3036
+ })
3037
+ : undefined,
3038
+ ])
3013
3039
 
3014
- // Token metadata is opt-in. Resolve once per pair and spread onto the
3015
- // top-level `base`/`quote` PairToken slots. The Level rows carry no
3016
- // token references so no per-row enrichment is needed.
3017
3040
  let base: z.output<typeof schema.PairToken> = { address: target.base }
3018
3041
  let quote: z.output<typeof schema.PairToken> = { address: target.quote }
3019
- if (options.include.includes('tokens')) {
3020
- const tokensByAddress = await Tokens.resolveTokens(c, {
3021
- addresses: [target.base, target.quote],
3022
- chainId,
3023
- include: tokenFields,
3024
- })
3042
+ if (tokensByAddress) {
3025
3043
  const baseMeta = tokensByAddress.get(target.base)
3026
3044
  const quoteMeta = tokensByAddress.get(target.quote)
3027
3045
  if (baseMeta) base = { ...base, ...baseMeta }
@@ -3536,9 +3554,9 @@ async function getSwaps(c: Context<App.Environment>, options: getSwaps.Options)
3536
3554
  // the head page.
3537
3555
  const cursor = cursorToken ? Cursor.decode(cursorToken, ['int', 'int', 'int']) : undefined
3538
3556
 
3539
- // Inclusive `dex_fills` block range bounding one grouped batch (see
3540
- // `swapWindowBlocks`).
3541
- type BlockWindow = { hi: number; lo: number }
3557
+ // `dex_fills` block window bounding one grouped batch (see
3558
+ // `swapWindowBlocks`); the head variant derives its lower bound in SQL.
3559
+ type BlockWindow = { hi: number; lo: number } | 'indexedHead'
3542
3560
 
3543
3561
  // Row-level predicates (constant within a group) prune before aggregation.
3544
3562
  // These are batch-invariant; the cursor's per-batch block bound is added in
@@ -3600,6 +3618,12 @@ async function getSwaps(c: Context<App.Environment>, options: getSwaps.Options)
3600
3618
  // the group timestamp.
3601
3619
  const fetchGroups = (activeCursor: typeof cursor, batchLimit: number, window: BlockWindow) => {
3602
3620
  const { having, where } = withCursorSql(activeCursor)
3621
+ const windowFilters =
3622
+ window === 'indexedHead'
3623
+ ? [
3624
+ `f.block_num >= greatest(toInt128((SELECT max(num) FROM blocks)) - ${swapWindowBlocks - 1}, toInt128(0))`,
3625
+ ]
3626
+ : [`f.block_num >= ${window.lo}`, `f.block_num <= ${window.hi}`]
3603
3627
  return tidx.fetch({
3604
3628
  chainId,
3605
3629
  engine: 'clickhouse',
@@ -3609,7 +3633,7 @@ async function getSwaps(c: Context<App.Environment>, options: getSwaps.Options)
3609
3633
  groupArray(tuple(f.log_idx, toString(f."orderId"), f.maker,
3610
3634
  toString(f."amountFilled"), f."partialFill")) AS fills
3611
3635
  FROM dex_fills f
3612
- WHERE ${[...where, `f.block_num >= ${window.lo}`, `f.block_num <= ${window.hi}`].join(' AND ')}
3636
+ WHERE ${[...where, ...windowFilters].join(' AND ')}
3613
3637
  GROUP BY f.block_num, f.tx_hash, f.taker
3614
3638
  ${having}
3615
3639
  ORDER BY block_num ${direction}, first_idx ${direction}
@@ -3667,19 +3691,16 @@ async function getSwaps(c: Context<App.Environment>, options: getSwaps.Options)
3667
3691
  }),
3668
3692
  )
3669
3693
 
3670
- // Each fill's book quote: from a targeted `dex_pairs` lookup of this
3671
- // batch's bases when indexed, else the base token's on-chain
3672
- // `quoteToken()` — some books predate the indexer's `PairCreated`
3673
- // decode (genesis system books) and have no `dex_pairs` row.
3694
+ // Each fill's book quote comes back with its order state when indexed;
3695
+ // otherwise read the base token's on-chain `quoteToken()` — some books
3696
+ // predate the indexer's `PairCreated` decode (genesis system books).
3674
3697
  const bases = [
3675
3698
  ...new Set([...states.values()].flatMap((events) => events.map((event) => event.token))),
3676
3699
  ]
3677
- const pairs = await getPairsByBase(c, { bases, chainId })
3678
3700
  const quoteByBase = new Map<string, z.output<typeof Schema.TokenAddress>>()
3679
- for (const bookBase of bases) {
3680
- const indexed = pairs.get(bookBase)
3681
- if (indexed) quoteByBase.set(bookBase, indexed.quote)
3682
- }
3701
+ for (const events of states.values())
3702
+ for (const event of events)
3703
+ if (event.quoteToken !== undefined) quoteByBase.set(event.token, event.quoteToken)
3683
3704
  const fallback = await Timing.time(c, 'swap_quotes', () =>
3684
3705
  resolveBookQuotes(c, {
3685
3706
  bases: bases.filter((bookBase) => !quoteByBase.has(bookBase)),
@@ -3763,7 +3784,12 @@ async function getSwaps(c: Context<App.Environment>, options: getSwaps.Options)
3763
3784
  return row ? Value.toNumber(row['block_num']) : undefined
3764
3785
  }
3765
3786
 
3766
- type ScanBounds = BlockWindow & { firstMatchingBlock?: number | undefined }
3787
+ type ScanBounds = {
3788
+ firstMatchingBlock?: number | undefined
3789
+ firstWindow?: 'indexedHead' | undefined
3790
+ hi: number
3791
+ lo: number
3792
+ }
3767
3793
  const bounds: ScanBounds | undefined = await Timing.time(c, 'swap_bounds', async () => {
3768
3794
  if (selective) {
3769
3795
  const firstMatchingBlock = await findMatchingBlock(cursorBlock)
@@ -3775,6 +3801,17 @@ async function getSwaps(c: Context<App.Environment>, options: getSwaps.Options)
3775
3801
  }
3776
3802
  : undefined
3777
3803
  }
3804
+ // The newest unfiltered page can derive its 100k-block lower bound from
3805
+ // `blocks` inside the grouped query, avoiding a separate TIDX request.
3806
+ if (
3807
+ order === 'desc' &&
3808
+ cursorBlock === undefined &&
3809
+ fromBlock === undefined &&
3810
+ fromTimestamp === undefined &&
3811
+ toBlock === undefined &&
3812
+ toTimestamp === undefined
3813
+ )
3814
+ return { firstWindow: 'indexedHead', hi: Number.MAX_SAFE_INTEGER, lo: 0 }
3778
3815
  const result = await tidx.fetch({
3779
3816
  chainId,
3780
3817
  engine: 'clickhouse',
@@ -3802,7 +3839,8 @@ async function getSwaps(c: Context<App.Environment>, options: getSwaps.Options)
3802
3839
  let activeCursor = cursor
3803
3840
 
3804
3841
  // Selective scans jump to the next populated block through a bounded
3805
- // lookup. Unfiltered scans retain the fixed-stride min/max sweep.
3842
+ // lookup. Unfiltered scans retain the fixed-stride min/max sweep after
3843
+ // the newest page's indexed-head window.
3806
3844
  const windowFrom = async (
3807
3845
  anchor: number | undefined,
3808
3846
  knownMatchingBlock?: number,
@@ -3821,13 +3859,20 @@ async function getSwaps(c: Context<App.Environment>, options: getSwaps.Options)
3821
3859
  : undefined
3822
3860
  }
3823
3861
 
3824
- let window = await windowFrom(cursorBlock, bounds.firstMatchingBlock)
3862
+ let window: BlockWindow | undefined =
3863
+ bounds.firstWindow ?? (await windowFrom(cursorBlock, bounds.firstMatchingBlock))
3825
3864
 
3826
3865
  // Slide to the next disjoint window away from the newest block and reset
3827
3866
  // the cursor: every row in the next window is already past the cursor
3828
3867
  // anchor in the requested order, so no anchor refinement is needed.
3829
- const slideWindow = async () => {
3868
+ const slideWindow = async (indexedHeadAnchor?: number) => {
3830
3869
  activeCursor = undefined
3870
+ if (window === 'indexedHead') {
3871
+ const matchingBlock = await findMatchingBlock(indexedHeadAnchor)
3872
+ window =
3873
+ matchingBlock === undefined ? undefined : await windowFrom(matchingBlock, matchingBlock)
3874
+ return
3875
+ }
3831
3876
  window =
3832
3877
  window === undefined
3833
3878
  ? undefined
@@ -3863,7 +3908,7 @@ async function getSwaps(c: Context<App.Environment>, options: getSwaps.Options)
3863
3908
  // A short batch exhausts this window; a full one may leave more groups
3864
3909
  // in it, so advance the keyset cursor and re-query the same window.
3865
3910
  if (!fetchedFull) {
3866
- await slideWindow()
3911
+ await slideWindow(order === 'desc' ? tail.blockNumber - 1 : tail.blockNumber + 1)
3867
3912
  continue
3868
3913
  }
3869
3914
  const next = cursorAfterWholeGroup(tail)
@@ -3896,13 +3941,7 @@ async function getSwaps(c: Context<App.Environment>, options: getSwaps.Options)
3896
3941
  ])
3897
3942
  : null
3898
3943
 
3899
- const data = pageRows.map((candidate) => candidate.swap)
3900
- // Recover each swap's `mode` from transaction calldata — deferred to here
3901
- // so the candidate scan never pays the `txs` lookup for rows beyond the
3902
- // page. Best-effort: a failed lookup leaves `mode: null`.
3903
- await resolveSwapModes(c, { chainId, swaps: data })
3904
-
3905
- return { data, nextCursor }
3944
+ return { data: pageRows.map((candidate) => candidate.swap), nextCursor }
3906
3945
  })
3907
3946
  }
3908
3947
 
@@ -4054,6 +4093,8 @@ declare namespace parseGroupRow {
4054
4093
  * history compressed to the latest event (`argMax`, sentinel position
4055
4094
  * `(-1, -1)` so it sorts first) plus every raw in-page event, so an order
4056
4095
  * that flips between two of its page fills resolves each fill correctly.
4096
+ * The same query attaches each book's indexed quote token, avoiding a second
4097
+ * TIDX request; callers retain an RPC fallback for books absent from the index.
4057
4098
  * `logs` is sorted by `(address, selector, block_num, log_idx)` with a
4058
4099
  * `topic1` bloom index, so both halves are pruned range reads. Returned
4059
4100
  * per-order event lists are position-sorted.
@@ -4078,16 +4119,27 @@ export async function resolveOrderStates(
4078
4119
  chainId,
4079
4120
  engine: 'clickhouse',
4080
4121
  query: `
4081
- SELECT topic1, argMax(topic3, (block_num, log_idx)) AS t3,
4082
- argMax(data, (block_num, log_idx)) AS payload,
4083
- toInt64(-1) AS bn, toInt32(-1) AS li
4084
- FROM logs
4085
- WHERE ${eventFilter} AND block_num < ${minBlock}
4086
- GROUP BY topic1
4087
- UNION ALL
4088
- SELECT topic1, topic3 AS t3, data AS payload, block_num AS bn, log_idx AS li
4089
- FROM logs
4090
- WHERE ${eventFilter} AND block_num >= ${minBlock} AND block_num <= ${maxBlock}
4122
+ WITH states AS (
4123
+ SELECT topic1, argMax(topic3, (block_num, log_idx)) AS t3,
4124
+ argMax(data, (block_num, log_idx)) AS payload,
4125
+ toInt64(-1) AS bn, toInt32(-1) AS li
4126
+ FROM logs
4127
+ WHERE ${eventFilter} AND block_num < ${minBlock}
4128
+ GROUP BY topic1
4129
+ UNION ALL
4130
+ SELECT topic1, topic3 AS t3, data AS payload, block_num AS bn, log_idx AS li
4131
+ FROM logs
4132
+ WHERE ${eventFilter} AND block_num >= ${minBlock} AND block_num <= ${maxBlock}
4133
+ )
4134
+ SELECT states.*, pairs.quote AS quote_token
4135
+ FROM states
4136
+ LEFT JOIN (
4137
+ -- Preserve the oldest-pair rule without sorting the complete table.
4138
+ SELECT base, argMin(quote, (block_num, log_idx)) AS quote
4139
+ FROM dex_pairs
4140
+ WHERE address = '${stablecoinDex}'
4141
+ GROUP BY base
4142
+ ) AS pairs ON pairs.base = concat('0x', lower(substring(states.t3, 27)))
4091
4143
  ` as string,
4092
4144
  })
4093
4145
 
@@ -4123,6 +4175,8 @@ declare namespace resolveOrderStates {
4123
4175
  isBid: boolean
4124
4176
  /** Event log index, or `-1` for the baseline. */
4125
4177
  logIndex: number
4178
+ /** Indexed quote token for the state's book, when available. */
4179
+ quoteToken?: z.output<typeof Schema.TokenAddress> | undefined
4126
4180
  /** Signed price tick at this state. */
4127
4181
  tick: number
4128
4182
  /** Book base token (lowercase address). */
@@ -4144,6 +4198,7 @@ function parseStateRow(
4144
4198
  const payload = Value.toText(row['payload'])
4145
4199
  const blockNumber = Value.toNumber(row['bn'])
4146
4200
  const logIndex = Value.toNumber(row['li'])
4201
+ const quoteToken = Schema.TokenAddress.safeParse(row['quote_token'])
4147
4202
  if (
4148
4203
  topic1 === undefined ||
4149
4204
  !/^0x[0-9a-fA-F]{64}$/.test(topic1) ||
@@ -4166,6 +4221,7 @@ function parseStateRow(
4166
4221
  blockNumber,
4167
4222
  isBid,
4168
4223
  logIndex,
4224
+ ...(quoteToken.success ? { quoteToken: quoteToken.data } : {}),
4169
4225
  tick,
4170
4226
  token: `0x${topic3.slice(26).toLowerCase()}`,
4171
4227
  },
@@ -4718,15 +4774,10 @@ const globalOrderScanCap = 2000
4718
4774
 
4719
4775
  /**
4720
4776
  * Batch size for `OrderCancelled` / `OrderFilled` `WHERE "orderId" IN (...)`
4721
- * lookups. ClickHouse evaluates the IN-list against the `topic1` bloom-filter
4722
- * index after the TIDX pushdown rewrite re-encodes each literal (see
4723
- * `tempoxyz/ak47` commit `273d88d`); very large lists still inflate SQL parse
4724
- * cost, but each batch is also an independent transient-error round trip, so
4725
- * 1000 balances parse cost against keeping the batch count (and thus the
4726
- * failure surface) low — a typical pair snapshot still resolves in one trip and
4727
- * the capped global feed in ~two.
4777
+ * lookups. A 2000-ID batch uses indexed state paths while native fill
4778
+ * aggregation keeps the response below SQL and result limits.
4728
4779
  */
4729
- const orderInBatchSize = 1000
4780
+ const orderInBatchSize = 2000
4730
4781
 
4731
4782
  /**
4732
4783
  * Signed-int16 bias used to encode `tick` in the cursor as a non-negative
@@ -4745,8 +4796,8 @@ const orderCursorTickBias = 32768
4745
4796
  * when scoped (string column with a bloom-filter index on `topic1`), and the
4746
4797
  * fill / cancellation joins use `WHERE "orderId" IN (...)` against the same
4747
4798
  * index. The decoded `tick` column has no ClickHouse index, so tick sort is
4748
- * still applied app-side. Aggregation also runs in the app because
4749
- * indexer-side `SUM(...)` over decoded columns is currently unreliable.
4799
+ * still applied app-side. ClickHouse aggregates fills per order before the
4800
+ * app computes each remaining amount.
4750
4801
  *
4751
4802
  * `remaining = OrderPlaced.amount - sum(OrderFilled.amountFilled)`; orders
4752
4803
  * with an `OrderCancelled` event are excluded outright. Each row is annotated
@@ -5012,7 +5063,7 @@ declare namespace getPairDepth {
5012
5063
  chainId: z.output<typeof Schema.ChainId>
5013
5064
  /** Maximum number of non-empty levels to keep per side, in best-first order. */
5014
5065
  levels: number
5015
- pair: getPairs.Row
5066
+ pair: getPairsByBase.Pair
5016
5067
  }
5017
5068
  }
5018
5069
 
@@ -5362,7 +5413,7 @@ async function fetchCancelledByIds(
5362
5413
  query: `
5363
5414
  SELECT "orderId"
5364
5415
  FROM OrderCancelled
5365
- WHERE "orderId" IN (${batch.join(', ')})
5416
+ WHERE address = '${stablecoinDex}' AND "orderId" IN (${batch.join(', ')})
5366
5417
  `,
5367
5418
  signatures: [orderCancelledSignature],
5368
5419
  })
@@ -5375,10 +5426,9 @@ async function fetchCancelledByIds(
5375
5426
  }
5376
5427
 
5377
5428
  /**
5378
- * Sums `amountFilled` per `orderId` across all matching `OrderFilled` rows in
5379
- * the snapshot window via batched `WHERE "orderId" IN (...)` queries. Each
5380
- * placement may have many partial fills, so the loader sums them in the app
5381
- * (indexer-side `SUM(...)` over decoded columns is currently unreliable).
5429
+ * Sums `amountFilled` per `orderId` across all matching native `dex_fills`
5430
+ * rows in the snapshot window via batched `WHERE "orderId" IN (...)` queries.
5431
+ * Native aggregation bounds results, and stringification preserves UInt256 precision through JSON parsing.
5382
5432
  */
5383
5433
  async function fetchFillSumsByIds(
5384
5434
  c: Context<App.Environment>,
@@ -5396,17 +5446,19 @@ async function fetchFillSumsByIds(
5396
5446
  const result = await tidx.fetch({
5397
5447
  chainId,
5398
5448
  engine: 'clickhouse',
5449
+ // Dynamic order IDs prevent signature-backed row inference; each aggregate is validated below before use.
5399
5450
  query: `
5400
- SELECT "orderId", "amountFilled"
5451
+ SELECT "orderId", toString(sum("amountFilled")) AS amount_filled
5401
5452
  FROM dex_fills
5402
- WHERE "orderId" IN (${batch.join(', ')})
5453
+ WHERE address = '${stablecoinDex}' AND "orderId" IN (${batch.join(', ')})
5454
+ GROUP BY "orderId"
5403
5455
  ` as string,
5404
5456
  })
5405
5457
  for (const row of result.rows) {
5406
5458
  const id = Value.toIntegerString(row['orderId'])
5407
- const filled = Value.toIntegerString(row['amountFilled'])
5459
+ const filled = Value.toIntegerString(row['amount_filled'])
5408
5460
  if (id === undefined || filled === undefined) continue
5409
- sums.set(id, (sums.get(id) ?? 0n) + BigInt(filled))
5461
+ sums.set(id, BigInt(filled))
5410
5462
  }
5411
5463
  }
5412
5464
  return sums