tapimo 0.9.0 → 0.10.1

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 (319) hide show
  1. package/dist/ApiKey.d.ts +2 -1
  2. package/dist/ApiKey.d.ts.map +1 -1
  3. package/dist/ApiKeys.d.ts +4 -2
  4. package/dist/ApiKeys.d.ts.map +1 -1
  5. package/dist/ApiKeys.js +26 -1
  6. package/dist/ApiKeys.js.map +1 -1
  7. package/dist/App.d.ts +4292 -3708
  8. package/dist/App.d.ts.map +1 -1
  9. package/dist/App.js +1 -0
  10. package/dist/App.js.map +1 -1
  11. package/dist/Webhooks.d.ts +21 -20
  12. package/dist/Webhooks.d.ts.map +1 -1
  13. package/dist/Webhooks.js +66 -23
  14. package/dist/Webhooks.js.map +1 -1
  15. package/dist/admin/App.d.ts +91 -84
  16. package/dist/admin/App.d.ts.map +1 -1
  17. package/dist/admin/apps/api-keys.d.ts +26 -17
  18. package/dist/admin/apps/api-keys.d.ts.map +1 -1
  19. package/dist/admin/apps/api-keys.js +6 -0
  20. package/dist/admin/apps/api-keys.js.map +1 -1
  21. package/dist/admin/apps/organizations.d.ts +4 -2
  22. package/dist/admin/apps/organizations.d.ts.map +1 -1
  23. package/dist/admin/apps/verified-tokens.d.ts +168 -168
  24. package/dist/admin/ui.generated.d.ts +1 -1
  25. package/dist/admin/ui.generated.js +69 -69
  26. package/dist/admin/ui.generated.js.map +1 -1
  27. package/dist/analytics/Analytics.d.ts.map +1 -1
  28. package/dist/analytics/Analytics.js +26 -0
  29. package/dist/analytics/Analytics.js.map +1 -1
  30. package/dist/apps/Zones.d.ts +4280 -84
  31. package/dist/apps/Zones.d.ts.map +1 -1
  32. package/dist/apps/Zones.js +30 -10
  33. package/dist/apps/Zones.js.map +1 -1
  34. package/dist/apps/data/App.d.ts +5799 -4929
  35. package/dist/apps/data/App.d.ts.map +1 -1
  36. package/dist/apps/data/FxOracle.d.ts +4 -5
  37. package/dist/apps/data/FxOracle.d.ts.map +1 -1
  38. package/dist/apps/data/FxOracle.js +19 -4
  39. package/dist/apps/data/FxOracle.js.map +1 -1
  40. package/dist/apps/data/routes/activities.d.ts +3391 -492
  41. package/dist/apps/data/routes/activities.d.ts.map +1 -1
  42. package/dist/apps/data/routes/activities.js +372 -29
  43. package/dist/apps/data/routes/activities.js.map +1 -1
  44. package/dist/apps/data/routes/addresses.d.ts +853 -339
  45. package/dist/apps/data/routes/addresses.d.ts.map +1 -1
  46. package/dist/apps/data/routes/balances.d.ts +10 -10
  47. package/dist/apps/data/routes/balances.d.ts.map +1 -1
  48. package/dist/apps/data/routes/balances.js +145 -83
  49. package/dist/apps/data/routes/balances.js.map +1 -1
  50. package/dist/apps/data/routes/blocks.d.ts +123 -123
  51. package/dist/apps/data/routes/coingecko.d.ts +169 -169
  52. package/dist/apps/data/routes/earn.d.ts +455 -1
  53. package/dist/apps/data/routes/earn.d.ts.map +1 -1
  54. package/dist/apps/data/routes/earn.js +369 -45
  55. package/dist/apps/data/routes/earn.js.map +1 -1
  56. package/dist/apps/data/routes/exchanges.d.ts.map +1 -1
  57. package/dist/apps/data/routes/exchanges.js +114 -118
  58. package/dist/apps/data/routes/exchanges.js.map +1 -1
  59. package/dist/apps/data/routes/fee-amm.d.ts +24 -24
  60. package/dist/apps/data/routes/indexer.d.ts +35 -34
  61. package/dist/apps/data/routes/indexer.d.ts.map +1 -1
  62. package/dist/apps/data/routes/indexer.js +3 -2
  63. package/dist/apps/data/routes/indexer.js.map +1 -1
  64. package/dist/apps/data/routes/receipts.d.ts.map +1 -1
  65. package/dist/apps/data/routes/receipts.js +68 -47
  66. package/dist/apps/data/routes/receipts.js.map +1 -1
  67. package/dist/apps/data/routes/rpc.d.ts +31 -31
  68. package/dist/apps/data/routes/rpc.d.ts.map +1 -1
  69. package/dist/apps/data/routes/rpc.js +5 -1
  70. package/dist/apps/data/routes/rpc.js.map +1 -1
  71. package/dist/apps/data/routes/tokenlist.d.ts +12 -12
  72. package/dist/apps/data/routes/tokens.d.ts +461 -459
  73. package/dist/apps/data/routes/tokens.d.ts.map +1 -1
  74. package/dist/apps/data/routes/transactions.d.ts +313 -305
  75. package/dist/apps/data/routes/transactions.d.ts.map +1 -1
  76. package/dist/apps/data/routes/transactions.js +25 -23
  77. package/dist/apps/data/routes/transactions.js.map +1 -1
  78. package/dist/apps/data/routes/transfers.d.ts +108 -86
  79. package/dist/apps/data/routes/transfers.d.ts.map +1 -1
  80. package/dist/apps/data/routes/transfers.js +618 -105
  81. package/dist/apps/data/routes/transfers.js.map +1 -1
  82. package/dist/apps/data/routes/valuation.d.ts +21 -21
  83. package/dist/apps/data/routes/valuation.d.ts.map +1 -1
  84. package/dist/apps/data/routes/valuation.js +2 -2
  85. package/dist/apps/data/routes/valuation.js.map +1 -1
  86. package/dist/apps/data/routes/webhooks.d.ts +519 -868
  87. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  88. package/dist/apps/data/routes/webhooks.js +642 -353
  89. package/dist/apps/data/routes/webhooks.js.map +1 -1
  90. package/dist/apps/data/routes/zones.d.ts +31 -31
  91. package/dist/apps/data/routes/zones.d.ts.map +1 -1
  92. package/dist/apps/data/routes/zones.js +4 -2
  93. package/dist/apps/data/routes/zones.js.map +1 -1
  94. package/dist/apps/funding/routes/deposit-addresses.js +1 -1
  95. package/dist/apps/funding/routes/deposit-addresses.js.map +1 -1
  96. package/dist/apps/funding/routes/providers.d.ts +13 -13
  97. package/dist/apps/funding/routes/quotes.d.ts +89 -89
  98. package/dist/apps/funding/routes/transfers.d.ts +36 -36
  99. package/dist/apps/funding/routes/transfers.js +1 -1
  100. package/dist/apps/funding/routes/transfers.js.map +1 -1
  101. package/dist/apps/management/App.d.ts +11 -309
  102. package/dist/apps/management/App.d.ts.map +1 -1
  103. package/dist/apps/management/routes/api-keys.d.ts +74 -74
  104. package/dist/apps/management/routes/billing.js +1 -1
  105. package/dist/apps/management/routes/billing.js.map +1 -1
  106. package/dist/apps/management/routes/faucet.d.ts +35 -35
  107. package/dist/apps/management/routes/faucet.js +2 -2
  108. package/dist/apps/management/routes/faucet.js.map +1 -1
  109. package/dist/apps/management/routes/invitations.d.ts +134 -134
  110. package/dist/apps/management/routes/invite-links.d.ts +80 -80
  111. package/dist/apps/management/routes/members.d.ts +69 -69
  112. package/dist/apps/management/routes/orgs.d.ts +98 -98
  113. package/dist/apps/management/routes/projects.d.ts +60 -60
  114. package/dist/apps/management/routes/usage.d.ts +72 -72
  115. package/dist/apps/management/routes/verified-token-requests.d.ts +83 -83
  116. package/dist/apps/management/routes/webhooks.d.ts +203 -618
  117. package/dist/apps/management/routes/webhooks.d.ts.map +1 -1
  118. package/dist/apps/mcp.d.ts.map +1 -1
  119. package/dist/apps/mcp.js +4 -1
  120. package/dist/apps/mcp.js.map +1 -1
  121. package/dist/apps/mpp/App.d.ts +56 -44
  122. package/dist/apps/mpp/App.d.ts.map +1 -1
  123. package/dist/apps/mpp/Mpp.d.ts +3 -0
  124. package/dist/apps/mpp/Mpp.d.ts.map +1 -1
  125. package/dist/apps/mpp/Mpp.js +3 -0
  126. package/dist/apps/mpp/Mpp.js.map +1 -1
  127. package/dist/apps/mpp/internal/Adapter.d.ts.map +1 -1
  128. package/dist/apps/mpp/internal/Adapter.js +9 -2
  129. package/dist/apps/mpp/internal/Adapter.js.map +1 -1
  130. package/dist/db/Db.d.ts +4 -2
  131. package/dist/db/Db.d.ts.map +1 -1
  132. package/dist/db/Db.js +75 -15
  133. package/dist/db/Db.js.map +1 -1
  134. package/dist/db/Schema.d.ts +19 -0
  135. package/dist/db/Schema.d.ts.map +1 -1
  136. package/dist/db/Schema.js +24 -0
  137. package/dist/db/Schema.js.map +1 -1
  138. package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
  139. package/dist/db/tables/sponsoredTransactions.js +4 -1
  140. package/dist/db/tables/sponsoredTransactions.js.map +1 -1
  141. package/dist/db/tables/webhookQueueEvents.d.ts +122 -0
  142. package/dist/db/tables/webhookQueueEvents.d.ts.map +1 -0
  143. package/dist/db/tables/webhookQueueEvents.js +160 -0
  144. package/dist/db/tables/webhookQueueEvents.js.map +1 -0
  145. package/dist/db/tables/webhookSubscriptions.d.ts +58 -3
  146. package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -1
  147. package/dist/db/tables/webhookSubscriptions.js +86 -3
  148. package/dist/db/tables/webhookSubscriptions.js.map +1 -1
  149. package/dist/internal/Auth.d.ts +3 -0
  150. package/dist/internal/Auth.d.ts.map +1 -1
  151. package/dist/internal/Auth.js +6 -4
  152. package/dist/internal/Auth.js.map +1 -1
  153. package/dist/internal/MetricSink.d.ts +139 -3
  154. package/dist/internal/MetricSink.d.ts.map +1 -1
  155. package/dist/internal/MetricSink.js +144 -4
  156. package/dist/internal/MetricSink.js.map +1 -1
  157. package/dist/internal/RateLimit.d.ts +4 -6
  158. package/dist/internal/RateLimit.d.ts.map +1 -1
  159. package/dist/internal/RateLimit.js +15 -14
  160. package/dist/internal/RateLimit.js.map +1 -1
  161. package/dist/internal/Schema.js +1 -1
  162. package/dist/internal/Schema.js.map +1 -1
  163. package/dist/internal/Store.d.ts +2 -1
  164. package/dist/internal/Store.d.ts.map +1 -1
  165. package/dist/internal/Store.js +57 -18
  166. package/dist/internal/Store.js.map +1 -1
  167. package/dist/internal/Tidx.d.ts +1 -3
  168. package/dist/internal/Tidx.d.ts.map +1 -1
  169. package/dist/internal/Tidx.js +1 -3
  170. package/dist/internal/Tidx.js.map +1 -1
  171. package/dist/internal/Value.d.ts +2 -0
  172. package/dist/internal/Value.d.ts.map +1 -1
  173. package/dist/internal/Value.js +25 -0
  174. package/dist/internal/Value.js.map +1 -1
  175. package/dist/internal/Viem.d.ts.map +1 -1
  176. package/dist/internal/Viem.js +2 -1
  177. package/dist/internal/Viem.js.map +1 -1
  178. package/dist/internal/WebhookDestination.d.ts +2 -0
  179. package/dist/internal/WebhookDestination.d.ts.map +1 -1
  180. package/dist/internal/WebhookDestination.js +11 -0
  181. package/dist/internal/WebhookDestination.js.map +1 -1
  182. package/dist/internal/WebhookTransfer.d.ts +75 -0
  183. package/dist/internal/WebhookTransfer.d.ts.map +1 -0
  184. package/dist/internal/WebhookTransfer.js +299 -0
  185. package/dist/internal/WebhookTransfer.js.map +1 -0
  186. package/dist/internal/Webhooks.d.ts +176 -0
  187. package/dist/internal/Webhooks.d.ts.map +1 -1
  188. package/dist/internal/Webhooks.js +216 -20
  189. package/dist/internal/Webhooks.js.map +1 -1
  190. package/dist/internal/funding/Provider.d.ts +4 -0
  191. package/dist/internal/funding/Provider.d.ts.map +1 -1
  192. package/dist/internal/funding/Provider.js +11 -7
  193. package/dist/internal/funding/Provider.js.map +1 -1
  194. package/dist/internal/funding/Reconciliation.d.ts.map +1 -1
  195. package/dist/internal/funding/Reconciliation.js +128 -0
  196. package/dist/internal/funding/Reconciliation.js.map +1 -1
  197. package/dist/internal/funding/providers/relay.d.ts +6 -4
  198. package/dist/internal/funding/providers/relay.d.ts.map +1 -1
  199. package/dist/internal/funding/providers/relay.js +71 -9
  200. package/dist/internal/funding/providers/relay.js.map +1 -1
  201. package/package.json +4 -4
  202. package/src/ApiKeys.test.ts +20 -0
  203. package/src/ApiKeys.ts +28 -1
  204. package/src/App.test.ts +10 -8
  205. package/src/App.ts +1 -0
  206. package/src/Client.test-d.ts +61 -5
  207. package/src/Webhooks.bench.ts +714 -0
  208. package/src/Webhooks.memory.bench.ts +250 -0
  209. package/src/Webhooks.test.ts +77 -6
  210. package/src/Webhooks.ts +85 -29
  211. package/src/admin/apps/api-keys.test.ts +9 -0
  212. package/src/admin/apps/api-keys.ts +15 -0
  213. package/src/admin/ui/src/lib/query-client.ts +3 -2
  214. package/src/admin/ui/src/routes/api-keys.tsx +31 -9
  215. package/src/admin/ui.generated.ts +69 -69
  216. package/src/analytics/Analytics.test.ts +8 -0
  217. package/src/analytics/Analytics.ts +26 -0
  218. package/src/apps/Zones.test.ts +23 -5
  219. package/src/apps/Zones.ts +31 -10
  220. package/src/apps/composition.test.ts +1 -0
  221. package/src/apps/data/App.test.ts +2 -2
  222. package/src/apps/data/FxOracle.test.ts +18 -0
  223. package/src/apps/data/FxOracle.ts +28 -5
  224. package/src/apps/data/routes/activities.bench.ts +41 -0
  225. package/src/apps/data/routes/activities.test.ts +122 -2
  226. package/src/apps/data/routes/activities.ts +435 -32
  227. package/src/apps/data/routes/balances.bench.ts +21 -0
  228. package/src/apps/data/routes/balances.test.ts +119 -10
  229. package/src/apps/data/routes/balances.ts +164 -97
  230. package/src/apps/data/routes/blocks.bench.ts +34 -0
  231. package/src/apps/data/routes/blocks.test.ts +12 -6
  232. package/src/apps/data/routes/coingecko.bench.ts +53 -0
  233. package/src/apps/data/routes/earn.bench.ts +71 -0
  234. package/src/apps/data/routes/earn.test.ts +197 -0
  235. package/src/apps/data/routes/earn.ts +487 -45
  236. package/src/apps/data/routes/exchanges.bench.ts +85 -0
  237. package/src/apps/data/routes/exchanges.test.ts +7 -6
  238. package/src/apps/data/routes/exchanges.ts +124 -117
  239. package/src/apps/data/routes/fee-amm.bench.ts +13 -0
  240. package/src/apps/data/routes/indexer.bench.ts +8 -0
  241. package/src/apps/data/routes/indexer.test.ts +2 -1
  242. package/src/apps/data/routes/indexer.ts +3 -2
  243. package/src/apps/data/routes/receipts.bench.ts +39 -0
  244. package/src/apps/data/routes/receipts.test.ts +24 -7
  245. package/src/apps/data/routes/receipts.ts +67 -50
  246. package/src/apps/data/routes/rpc.bench.ts +14 -0
  247. package/src/apps/data/routes/rpc.test.ts +27 -0
  248. package/src/apps/data/routes/rpc.ts +5 -1
  249. package/src/apps/data/routes/tokenlist.bench.ts +3 -0
  250. package/src/apps/data/routes/tokens.bench.ts +76 -0
  251. package/src/apps/data/routes/tokens.test.ts +51 -0
  252. package/src/apps/data/routes/transactions.bench.ts +40 -0
  253. package/src/apps/data/routes/transactions.test.ts +42 -0
  254. package/src/apps/data/routes/transactions.ts +44 -39
  255. package/src/apps/data/routes/transfers.bench.ts +28 -0
  256. package/src/apps/data/routes/transfers.test.ts +402 -24
  257. package/src/apps/data/routes/transfers.ts +708 -101
  258. package/src/apps/data/routes/valuation.bench.ts +14 -0
  259. package/src/apps/data/routes/valuation.test.ts +26 -0
  260. package/src/apps/data/routes/valuation.ts +6 -2
  261. package/src/apps/data/routes/verified-tokens.bench.ts +8 -0
  262. package/src/apps/data/routes/webhooks.bench.ts +26 -0
  263. package/src/apps/data/routes/webhooks.scan.test.ts +326 -0
  264. package/src/apps/data/routes/webhooks.test.ts +466 -135
  265. package/src/apps/data/routes/webhooks.ts +820 -429
  266. package/src/apps/data/routes/zones.bench.ts +32 -0
  267. package/src/apps/data/routes/zones.test.ts +1 -1
  268. package/src/apps/data/routes/zones.ts +7 -2
  269. package/src/apps/funding/routes/chains.test.ts +1 -1
  270. package/src/apps/funding/routes/deposit-addresses.test.ts +1 -1
  271. package/src/apps/funding/routes/deposit-addresses.ts +1 -1
  272. package/src/apps/funding/routes/providers.test.ts +1 -1
  273. package/src/apps/funding/routes/quotes.test.ts +2 -2
  274. package/src/apps/funding/routes/transfers.test.ts +3 -6
  275. package/src/apps/funding/routes/transfers.ts +1 -1
  276. package/src/apps/management/routes/billing.ts +1 -1
  277. package/src/apps/management/routes/faucet.ts +2 -2
  278. package/src/apps/management/routes/me.test.ts +31 -0
  279. package/src/apps/mcp.ts +4 -1
  280. package/src/apps/mpp/Mpp.test.ts +13 -1
  281. package/src/apps/mpp/Mpp.ts +8 -0
  282. package/src/apps/mpp/internal/Adapter.ts +10 -2
  283. package/src/db/Db.test.ts +40 -6
  284. package/src/db/Db.ts +114 -19
  285. package/src/db/Schema.ts +32 -0
  286. package/src/db/tables/sponsoredTransactions.ts +9 -2
  287. package/src/db/tables/webhookQueueEvents.ts +285 -0
  288. package/src/db/tables/webhookSubscriptions.ts +131 -4
  289. package/src/internal/Auth.test.ts +61 -50
  290. package/src/internal/Auth.ts +9 -4
  291. package/src/internal/EarnVaults.test.ts +2 -2
  292. package/src/internal/MetricSink.test.ts +575 -0
  293. package/src/internal/MetricSink.ts +325 -10
  294. package/src/internal/RateLimit.test.ts +23 -18
  295. package/src/internal/RateLimit.ts +23 -15
  296. package/src/internal/Schema.ts +1 -1
  297. package/src/internal/Store.test.ts +99 -13
  298. package/src/internal/Store.ts +52 -17
  299. package/src/internal/Tidx.test.ts +4 -4
  300. package/src/internal/Tidx.ts +1 -3
  301. package/src/internal/Value.test.ts +25 -0
  302. package/src/internal/Value.ts +29 -0
  303. package/src/internal/Viem.test.ts +8 -0
  304. package/src/internal/Viem.ts +2 -1
  305. package/src/internal/WebhookDestination.test.ts +20 -0
  306. package/src/internal/WebhookDestination.ts +10 -0
  307. package/src/internal/WebhookTransfer.test.ts +89 -0
  308. package/src/internal/WebhookTransfer.ts +430 -0
  309. package/src/internal/Webhooks.test.ts +616 -0
  310. package/src/internal/Webhooks.ts +412 -24
  311. package/src/internal/funding/Provider.test.ts +48 -8
  312. package/src/internal/funding/Provider.ts +12 -7
  313. package/src/internal/funding/Reconciliation.local.test.ts +3 -1
  314. package/src/internal/funding/Reconciliation.test.ts +1240 -0
  315. package/src/internal/funding/Reconciliation.ts +136 -0
  316. package/src/internal/funding/SourceObservation.test.ts +1 -0
  317. package/src/internal/funding/providers/index.test.ts +73 -3
  318. package/src/internal/funding/providers/relay.test.ts +2 -9
  319. package/src/internal/funding/providers/relay.ts +72 -13
@@ -297,8 +297,8 @@ describe('GET /addresses/:address/balances', () => {
297
297
  currency: 'USD',
298
298
  decimals: 6,
299
299
  logoUri: 'https://example.com/pathusd.svg',
300
- name: 'PathUSD',
301
- symbol: 'pathUSD',
300
+ name: 'Curated PathUSD',
301
+ symbol: 'curatedUSD',
302
302
  },
303
303
  ],
304
304
  })
@@ -321,9 +321,49 @@ describe('GET /addresses/:address/balances', () => {
321
321
  const body = await TestApp.json(response, Balances.schema.getAddressBalances.Response)
322
322
  expect(response.status).toBe(200)
323
323
  expect(body.data.length).toBeGreaterThan(0)
324
+ expect(response.headers.get('server-timing')).not.toContain('token_logo;dur=')
325
+ expect(response.headers.get('server-timing')).not.toContain('token_metadata;dur=')
324
326
  for (const balance of body.data) expect(balance.token.verified).toBe(true)
325
327
  const pathUsd = body.data.find((balance) => balance.token.address === TestApp.tokenWithHolders)
326
- expect(pathUsd?.token.logoUri).toMatch(/^http/)
328
+ expect(pathUsd?.token).toMatchObject({
329
+ logoUri: 'https://example.com/pathusd.svg',
330
+ name: 'Curated PathUSD',
331
+ symbol: 'curatedUSD',
332
+ })
333
+ })
334
+
335
+ test('includes uploaded logos for verified tokens without a logo URI', async () => {
336
+ const logoDb = TestApp.database()
337
+ await TestApp.verifiedSeed(logoDb, runtime.chainId, {
338
+ tokens: [
339
+ {
340
+ address: TestApp.tokenWithHolders,
341
+ currency: 'USD',
342
+ decimals: 6,
343
+ name: 'Curated PathUSD',
344
+ symbol: 'curatedUSD',
345
+ },
346
+ ],
347
+ })
348
+ const client = app({ db: logoDb })
349
+ const holder = await holderWithBalance
350
+
351
+ const response = await client.v1.addresses[':address'].balances.$get(
352
+ {
353
+ param: { address: holder },
354
+ query: { limit: '20', verified: 'true' },
355
+ },
356
+ TestApp.auth,
357
+ )
358
+ const body = await TestApp.json(response, Balances.schema.getAddressBalances.Response)
359
+ const pathUsd = body.data.find((balance) => balance.token.address === TestApp.tokenWithHolders)
360
+
361
+ expect(response.status).toBe(200)
362
+ expect(response.headers.get('server-timing')).toContain('token_logo;dur=')
363
+ expect(response.headers.get('server-timing')).not.toContain('token_metadata;dur=')
364
+ expect(pathUsd?.token.logoUri).toBe(
365
+ `http://localhost/assets/${runtime.chainId}/icons/${TestApp.tokenWithHolders}`,
366
+ )
327
367
  })
328
368
 
329
369
  test('narrows balances by currency and retains verification', async () => {
@@ -360,7 +400,23 @@ describe('GET /addresses/:address/balances', () => {
360
400
  for (const balance of body.data) expect(typeof balance.feeEligible).toBe('boolean')
361
401
  })
362
402
 
363
- test('values each verified holding in the default denomination', async () => {
403
+ test('skips the fee-token lookup when an address has no holdings', async () => {
404
+ const client = app()
405
+ const response = await client.v1.addresses[':address'].balances.$get(
406
+ {
407
+ param: { address: privateKeyToAccount(generatePrivateKey()).address },
408
+ query: {},
409
+ },
410
+ TestApp.auth,
411
+ )
412
+ const body = await TestApp.json(response, Balances.schema.getAddressBalances.Response)
413
+
414
+ expect(response.status).toBe(200)
415
+ expect(body.data).toEqual([])
416
+ expect(response.headers.get('server-timing')).not.toContain('fee_token_set;dur=')
417
+ })
418
+
419
+ test('omits valuations unless a denomination is requested', async () => {
364
420
  await TestApp.verifiedSeed(db, runtime.chainId)
365
421
  const client = app()
366
422
  const holder = await holderWithBalance
@@ -371,12 +427,8 @@ describe('GET /addresses/:address/balances', () => {
371
427
  )
372
428
  const body = await TestApp.json(response, Balances.schema.getAddressBalances.Response)
373
429
  expect(response.status).toBe(200)
374
-
375
- // The guaranteed pathUSD holding is curated USD, so its USD value is the
376
- // holding itself; unverified holdings are never valued.
377
- const pathUsd = body.data.find((entry) => entry.token.address === TestApp.tokenWithHolders)
378
- expect(pathUsd?.valuation).toEqual({ amount: pathUsd!.formatted, currency: 'USD' })
379
- for (const entry of body.data) if (!entry.token.verified) expect(entry.valuation).toBeNull()
430
+ expect(response.headers.get('server-timing')).not.toContain('valuation_rates;dur=')
431
+ for (const entry of body.data) expect('valuation' in entry).toBe(false)
380
432
  })
381
433
 
382
434
  test('values holdings in a requested denomination', async () => {
@@ -444,6 +496,26 @@ describe('GET /addresses/:address/balances', () => {
444
496
  expect(stable.error.code).toMatchInlineSnapshot(`"query_invalid"`)
445
497
  })
446
498
 
499
+ test('rejects unsupported denominations before balance RPC failures', async () => {
500
+ const client = app({
501
+ fx: { oracle: fixed },
502
+ rpc: { url: () => 'http://127.0.0.1:1' },
503
+ })
504
+ const holder = await holderWithBalance
505
+
506
+ const response = await client.v1.addresses[':address'].balances.$get(
507
+ {
508
+ param: { address: holder },
509
+ query: { 'valuation.currency': 'JPY' },
510
+ },
511
+ TestApp.auth,
512
+ )
513
+ const body = await TestApp.json(response, Schema.ErrorResponse)
514
+
515
+ expect(response.status).toMatchInlineSnapshot(`400`)
516
+ expect(body.error.code).toMatchInlineSnapshot(`"query_invalid"`)
517
+ })
518
+
447
519
  test('restricts the page to fee tokens when feeEligible=true', async () => {
448
520
  const client = app()
449
521
  const holder = await holderWithBalance
@@ -489,7 +561,44 @@ describe('GET /v1/addresses/:address/balances/:token', () => {
489
561
  expect(body.token.address).toBe(TestApp.tokenWithHolders)
490
562
  expect(body.amount).toMatch(/^\d+$/)
491
563
  expect(core_Value.from(body.formatted, body.decimals)).toBe(BigInt(body.amount))
564
+ expect('valuation' in body).toBe(false)
565
+ expect(response.headers.get('server-timing')).not.toContain('valuation_rates;dur=')
566
+ })
567
+
568
+ test('values one balance when a denomination is requested', async () => {
569
+ const client = app()
570
+ const holder = await holderWithBalance
571
+ const response = await client.v1.addresses[':address'].balances[':token'].$get(
572
+ {
573
+ param: { address: holder, token: TestApp.tokenWithHolders },
574
+ query: { 'valuation.currency': 'USD' },
575
+ },
576
+ TestApp.auth,
577
+ )
578
+ const body = await TestApp.json(response, Balances.schema.getAddressBalance.Response)
579
+
580
+ expect(response.status).toBe(200)
492
581
  expect(body.valuation).toEqual({ amount: body.formatted, currency: 'USD' })
582
+ expect(response.headers.get('server-timing')).toContain('valuation_rates;dur=')
583
+ })
584
+
585
+ test('rejects an unsupported denomination before a balance RPC failure', async () => {
586
+ const client = app({
587
+ fx: { oracle: fixed },
588
+ rpc: { url: () => 'http://127.0.0.1:1' },
589
+ })
590
+ const holder = await holderWithBalance
591
+ const response = await client.v1.addresses[':address'].balances[':token'].$get(
592
+ {
593
+ param: { address: holder, token: TestApp.tokenWithHolders },
594
+ query: { 'valuation.currency': 'JPY' },
595
+ },
596
+ TestApp.auth,
597
+ )
598
+ const body = await TestApp.json(response, Schema.ErrorResponse)
599
+
600
+ expect(response.status).toMatchInlineSnapshot(`400`)
601
+ expect(body.error.code).toMatchInlineSnapshot(`"query_invalid"`)
493
602
  })
494
603
 
495
604
  test('returns zero for a token the account does not hold', async () => {
@@ -66,7 +66,12 @@ export namespace schema {
66
66
  include: Schema.totalCountInclude,
67
67
  limit: Schema.Limit,
68
68
  page: Schema.Page,
69
- 'valuation.currency': Schema.Denomination,
69
+ 'valuation.currency': Schema.Denomination.check(
70
+ z.describe(
71
+ 'When present, include each holding’s nominal value in this denomination. ' +
72
+ 'Case-insensitive and must be priced by the configured FX oracle.',
73
+ ),
74
+ ),
70
75
  verified: z
71
76
  .optional(Schema.booleanQuery())
72
77
  .check(
@@ -141,11 +146,11 @@ export namespace schema {
141
146
  ),
142
147
  token: Tokens.schema.Token,
143
148
  valuation: z
144
- .nullable(Valuation.schema.Value)
149
+ .optional(z.nullable(Valuation.schema.Value))
145
150
  .check(
146
151
  z.describe(
147
- 'The holding’s nominal value in the requested `valuation.currency`, or `null` when the ' +
148
- 'token is unverified, its display currency has no rate, or rates were unavailable.',
152
+ 'The holding’s nominal value when `valuation.currency` is requested, or `null` when ' +
153
+ 'the token is unverified, its display currency has no rate, or rates were unavailable.',
149
154
  ),
150
155
  ),
151
156
  }),
@@ -198,7 +203,12 @@ export namespace schema {
198
203
  export const Query = z
199
204
  .strictObject({
200
205
  chainId: Schema.ChainIdQuery,
201
- 'valuation.currency': Schema.Denomination,
206
+ 'valuation.currency': Schema.Denomination.check(
207
+ z.describe(
208
+ 'When present, include the holding’s nominal value in this denomination. ' +
209
+ 'Case-insensitive and must be priced by the configured FX oracle.',
210
+ ),
211
+ ),
202
212
  })
203
213
  .check(z.describe('Query parameters for reading one account token balance.'))
204
214
 
@@ -229,7 +239,7 @@ export function addresses(options: addresses.Options = {}) {
229
239
  }),
230
240
  OpenApi.describeRoute({
231
241
  description:
232
- 'Lists how much of each TIP-20 token an account holds, ordered from largest to smallest balance. Amounts are returned in raw base units and human-readable form, and each verified holding carries its nominal value in the requested denomination.',
242
+ 'Lists how much of each TIP-20 token an account holds, ordered from largest to smallest balance. Amounts are returned in raw base units and human-readable form. Pass `valuation.currency` to include nominal values.',
233
243
  operationId: 'getAddressBalances',
234
244
  responses: OpenApi.responses({
235
245
  errors: { 502: 'Could not read balance or token data from an upstream service.' },
@@ -269,26 +279,13 @@ export function addresses(options: addresses.Options = {}) {
269
279
  const verified = query.verified || query.currency !== undefined
270
280
  const feeEligible = query.feeEligible ?? false
271
281
 
272
- // The fee-token set powers both the per-row `feeEligible` flag (always)
273
- // and the optional `feeEligible=true` page restriction. It is memoized
274
- // per chain (a near-constant), so one read feeds the page, the count,
275
- // and the row enrichment. It is a best-effort enrichment: a lookup
276
- // failure omits `feeEligible` rather than failing the page — unless the
277
- // caller explicitly asked to filter by `feeEligible`, where the filter
278
- // can't be honored, so the failure surfaces as the declared `502`.
279
- const feeTokens = await FeeAmm.feeTokenSet(c, chainId).catch((cause) => {
280
- if (feeEligible) throw cause
281
- return undefined
282
- })
283
-
284
282
  const balances = await getAddressBalances(c, {
285
283
  address,
286
284
  chainId,
287
285
  currency: query.currency,
288
286
  cursor: query.cursor,
289
- denomination: query['valuation.currency'] ?? 'USD',
287
+ denomination: query['valuation.currency'],
290
288
  feeEligible,
291
- feeTokens,
292
289
  includeTotalCount: query.include.includes('totalCount'),
293
290
  limit: query.limit,
294
291
  oracle,
@@ -356,29 +353,43 @@ export function addresses(options: addresses.Options = {}) {
356
353
  const { address, token } = c.req.valid('param')
357
354
  const query = c.req.valid('query')
358
355
  const chainId = query.chainId ?? c.get('chainId')
356
+ const denomination = query['valuation.currency']
359
357
 
360
358
  try {
361
- const snapshot = await VerifiedTokens.snapshot(c, chainId)
362
- const [balance, resolvedToken] = await Promise.all([
359
+ const rates = denomination
360
+ ? await Valuation.rateSet(c, oracle)
361
+ .then((rates) => {
362
+ if (denomination !== rates.base && rates.rates[denomination] === undefined)
363
+ throw new Valuation.UnsupportedDenominationError(denomination, oracle.name)
364
+ return rates
365
+ })
366
+ .catch((cause) => {
367
+ if (cause instanceof Valuation.UnsupportedDenominationError) throw cause
368
+ return undefined
369
+ })
370
+ : undefined
371
+ const snapshotPromise = VerifiedTokens.snapshot(c, chainId)
372
+ const resolvedTokenPromise = snapshotPromise.then((snapshot) =>
373
+ Timing.time(c, 'token', () =>
374
+ Tokens.resolveToken(c, { address: token, chainId, include: [], snapshot }),
375
+ ),
376
+ )
377
+ const client = c.get('getClient')(chainId)
378
+ const caller = c.get('zones').has(chainId)
379
+ ? (await client.zone.getZoneInfo()).sequencers[0]
380
+ : undefined
381
+ const [balance, feeTokens, snapshot, resolvedToken] = await Promise.all([
363
382
  Timing.time(c, 'balance_rpc', async () => {
364
- const result = await Actions.token.getBalance(c.get('getClient')(chainId), {
383
+ const call = Actions.token.getBalance.call(client, {
365
384
  account: address,
366
385
  token,
367
386
  })
368
- return result.amount
387
+ return client.readContract({ ...call, account: caller })
369
388
  }),
370
- Timing.time(c, 'token', () =>
371
- Tokens.resolveToken(c, { address: token, chainId, include: [], snapshot }),
372
- ),
389
+ FeeAmm.feeTokenSet(c, chainId).catch(() => undefined),
390
+ snapshotPromise,
391
+ resolvedTokenPromise,
373
392
  ])
374
- const denomination = query['valuation.currency'] ?? 'USD'
375
- const rates = await Valuation.ratesFor(c, {
376
- currencies: snapshot.byAddress.has(token) ? [resolvedToken.currency] : [],
377
- denomination,
378
- oracle,
379
- })
380
- const feeTokens = await FeeAmm.feeTokenSet(c, chainId).catch(() => undefined)
381
-
382
393
  return c.json(
383
394
  Response.validated(schema.getAddressBalance.Response, {
384
395
  amount: balance.toString(),
@@ -388,12 +399,16 @@ export function addresses(options: addresses.Options = {}) {
388
399
  formatted: core_Value.format(balance, resolvedToken.decimals),
389
400
  id: token,
390
401
  token: resolvedToken,
391
- valuation: Valuation.valuationFor({
392
- amount: balance,
393
- denomination,
394
- rates,
395
- token: snapshot.byAddress.get(token),
396
- }),
402
+ ...(denomination
403
+ ? {
404
+ valuation: Valuation.valuationFor({
405
+ amount: balance,
406
+ denomination,
407
+ rates,
408
+ token: snapshot.byAddress.get(token),
409
+ }),
410
+ }
411
+ : {}),
397
412
  }),
398
413
  200,
399
414
  )
@@ -430,7 +445,6 @@ async function getAddressBalances(
430
445
  currency,
431
446
  denomination,
432
447
  feeEligible,
433
- feeTokens,
434
448
  includeTotalCount,
435
449
  limit,
436
450
  oracle,
@@ -440,8 +454,9 @@ async function getAddressBalances(
440
454
  const cursor = options.cursor ? Cursor.decode(options.cursor, ['uint', 'address']) : undefined
441
455
  const offset = options.page !== undefined && options.page > 1 ? (options.page - 1) * limit : 0
442
456
 
443
- // Validate the requested denomination independently of the current page.
444
- await Valuation.ratesFor(c, { currencies: [], denomination, oracle })
457
+ // Validate an explicit denomination before balance discovery and correction
458
+ // so dependency failures cannot mask a caller error.
459
+ if (denomination) await Valuation.ratesFor(c, { currencies: [], denomination, oracle })
445
460
 
446
461
  // Build the optional `token IN (…)` restriction before hitting TIDX. The
447
462
  // verified/`currency` filter and the `feeEligible` filter each contribute a set
@@ -450,17 +465,32 @@ async function getAddressBalances(
450
465
  // intersection. If any active filter rules every token out, return an empty
451
466
  // page without a round trip. The snapshot is reused for the page enrichment
452
467
  // below.
453
- const snapshot = await VerifiedTokens.snapshot(c, chainId)
454
- const allowed = allowedTokens({ currency, feeEligible, feeTokens, snapshot, verified })
468
+ const snapshotPromise = VerifiedTokens.snapshot(c, chainId)
469
+ // Fee filtering can fail before the concurrently started snapshot is awaited.
470
+ // Observing this branch prevents an unhandled rejection; the later await still propagates it.
471
+ void snapshotPromise.catch(() => undefined)
472
+ const [snapshot_filter, feeTokens_filter] = await Promise.all([
473
+ verified ? snapshotPromise : Promise.resolve(undefined),
474
+ feeEligible ? FeeAmm.feeTokenSet(c, chainId) : Promise.resolve(undefined),
475
+ ])
476
+ const allowed = allowedTokens({
477
+ currency,
478
+ feeEligible,
479
+ feeTokens: feeTokens_filter,
480
+ snapshot: snapshot_filter,
481
+ verified,
482
+ })
455
483
  const tokenFilter = allowed?.map((address) => `'${address}'`)
456
- if (tokenFilter?.length === 0)
484
+ if (tokenFilter?.length === 0) {
485
+ await snapshotPromise
457
486
  return {
458
487
  data: [],
459
488
  ...(includeTotalCount ? { meta: { totalCount: 0, totalCountCapped: false } } : {}),
460
489
  nextCursor: null,
461
490
  }
491
+ }
462
492
 
463
- const holdings = await Timing.time(c, 'balances_discovery', async () => {
493
+ const holdingsPromise = Timing.time(c, 'balances_discovery', async () => {
464
494
  const tokenIn = tokenFilter ? ` AND token IN (${tokenFilter.join(', ')})` : ''
465
495
 
466
496
  const result = await tidx.fetch({
@@ -481,36 +511,58 @@ async function getAddressBalances(
481
511
  })
482
512
  })
483
513
 
484
- if (holdings.length === 0)
514
+ // Correction starts as soon as discovery completes. Snapshot and fee-token
515
+ // cache misses continue concurrently until enrichment needs their results.
516
+ const correctedPromise = holdingsPromise.then(async (holdings) => {
517
+ if (holdings.length === 0) return []
518
+ const client = c.get('getClient')(chainId)
519
+ const caller = c.get('zones').has(chainId)
520
+ ? (await client.zone.getZoneInfo()).sequencers[0]
521
+ : undefined
522
+ return Timing.time(c, 'balances_rpc', async () => {
523
+ // Concurrent reads enter viem's shared batch window and become one
524
+ // deployless multicall. A failed read fails the request rather than serving
525
+ // a stale or partial balance page.
526
+ const results = await Promise.all(
527
+ holdings.map(async (holding) => {
528
+ const call = Actions.token.getBalance.call(client, {
529
+ account: address,
530
+ token: holding.address,
531
+ })
532
+ const amount = await client.readContract({ ...call, account: caller })
533
+ return amount === 0n ? undefined : { address: holding.address, amount: amount.toString() }
534
+ }),
535
+ )
536
+ const rows = results.filter((holding) => holding !== undefined)
537
+
538
+ rows.sort((a, b) => {
539
+ const delta = BigInt(b.amount) - BigInt(a.amount)
540
+ if (delta !== 0n) return delta > 0n ? 1 : -1
541
+ return a.address < b.address ? -1 : a.address > b.address ? 1 : 0
542
+ })
543
+ return rows
544
+ })
545
+ })
546
+ const feeTokensPromise = feeEligible
547
+ ? Promise.resolve(feeTokens_filter)
548
+ : holdingsPromise.then((holdings) =>
549
+ holdings.length === 0 ? undefined : FeeAmm.feeTokenSet(c, chainId).catch(() => undefined),
550
+ )
551
+
552
+ const [holdings, snapshot, feeTokens, corrected] = await Promise.all([
553
+ holdingsPromise,
554
+ snapshotPromise,
555
+ feeTokensPromise,
556
+ correctedPromise,
557
+ ])
558
+
559
+ if (holdings.length === 0) {
485
560
  return {
486
561
  data: [],
487
562
  ...(includeTotalCount ? { meta: { totalCount: 0, totalCountCapped: false } } : {}),
488
563
  nextCursor: null,
489
564
  }
490
-
491
- const client = c.get('getClient')(chainId)
492
- const corrected = await Timing.time(c, 'balances_rpc', async () => {
493
- // Concurrent reads enter viem's shared batch window and become one
494
- // deployless multicall. A failed read fails the request rather than serving
495
- // a stale or partial balance page.
496
- const results = await Promise.all(
497
- holdings.map(async (holding) => {
498
- const { amount } = await Actions.token.getBalance(client, {
499
- account: address,
500
- token: holding.address,
501
- })
502
- return amount === 0n ? undefined : { address: holding.address, amount: amount.toString() }
503
- }),
504
- )
505
- const rows = results.filter((holding) => holding !== undefined)
506
-
507
- rows.sort((a, b) => {
508
- const delta = BigInt(b.amount) - BigInt(a.amount)
509
- if (delta !== 0n) return delta > 0n ? 1 : -1
510
- return a.address < b.address ? -1 : a.address > b.address ? 1 : 0
511
- })
512
- return rows
513
- })
565
+ }
514
566
 
515
567
  const positioned = (() => {
516
568
  if (!cursor) return corrected.slice(offset)
@@ -528,7 +580,7 @@ async function getAddressBalances(
528
580
  key: (holding) => [holding.amount, holding.address],
529
581
  })
530
582
 
531
- if (page.rows.length === 0)
583
+ if (page.rows.length === 0) {
532
584
  return {
533
585
  data: [],
534
586
  ...(includeTotalCount
@@ -536,22 +588,38 @@ async function getAddressBalances(
536
588
  : {}),
537
589
  nextCursor: page.nextCursor,
538
590
  }
591
+ }
539
592
 
540
- const rates = await Valuation.ratesFor(c, {
541
- currencies: page.rows.flatMap((row) => {
542
- const held = snapshot.byAddress.get(row.address.toLowerCase())?.currency
543
- return held === undefined ? [] : [held]
544
- }),
545
- denomination,
546
- oracle,
547
- })
593
+ const ratesPromise = denomination
594
+ ? Valuation.ratesFor(c, {
595
+ currencies: page.rows.flatMap((row) => {
596
+ const held = snapshot.byAddress.get(row.address.toLowerCase())?.currency
597
+ return held === undefined ? [] : [held]
598
+ }),
599
+ denomination,
600
+ oracle,
601
+ })
602
+ : Promise.resolve(undefined)
548
603
 
549
604
  // Balance tokens retain their logo and verification metadata by default.
550
- const resolved = await Timing.time(c, 'tokens', () =>
605
+ const resolvedPromise = Timing.time(c, 'tokens', () =>
551
606
  Promise.all(
552
607
  page.rows.map(async (holding) => {
553
608
  try {
554
609
  const token = Schema.Address.parse(holding.address.toLowerCase())
610
+ const verifiedToken = snapshot.byAddress.get(token)
611
+ // The curated list owns verified-token metadata, but uploaded logos live only in the asset store.
612
+ if (verifiedToken)
613
+ return {
614
+ amount: holding.amount,
615
+ logoUri:
616
+ verifiedToken.logoUri ??
617
+ (await Timing.time(c, 'token_logo', () =>
618
+ Tokens.getTokenLogo(c, { address: token, chainId }),
619
+ ).catch(() => undefined)),
620
+ metadata: verifiedToken,
621
+ token,
622
+ }
555
623
  const [logoUri, metadata] = await Promise.all([
556
624
  Timing.time(c, 'token_logo', () =>
557
625
  Tokens.getTokenLogo(c, { address: token, chainId }),
@@ -567,6 +635,7 @@ async function getAddressBalances(
567
635
  }),
568
636
  ),
569
637
  )
638
+ const [rates, resolved] = await Promise.all([ratesPromise, resolvedPromise])
570
639
 
571
640
  const data: z.output<typeof schema.getAddressBalances.Balance>[] = []
572
641
  for (const entry of resolved) {
@@ -591,12 +660,16 @@ async function getAddressBalances(
591
660
  symbol: entry.metadata.symbol,
592
661
  verified: snapshot.byAddress.has(entry.token),
593
662
  },
594
- valuation: Valuation.valuationFor({
595
- amount: BigInt(entry.amount),
596
- denomination,
597
- rates,
598
- token: snapshot.byAddress.get(entry.token),
599
- }),
663
+ ...(denomination
664
+ ? {
665
+ valuation: Valuation.valuationFor({
666
+ amount: BigInt(entry.amount),
667
+ denomination,
668
+ rates,
669
+ token: snapshot.byAddress.get(entry.token),
670
+ }),
671
+ }
672
+ : {}),
600
673
  })
601
674
  }
602
675
 
@@ -620,15 +693,9 @@ declare namespace getAddressBalances {
620
693
  /** Opaque keyset cursor anchoring the page; omit for the head page. */
621
694
  cursor?: string | undefined
622
695
  /** Currency the per-row valuations are denominated in (uppercase). */
623
- denomination: string
624
- /** When true, restrict the page to fee-usable tokens (members of `feeTokens`). */
696
+ denomination?: string | undefined
697
+ /** When true, restrict the page to fee-usable tokens. */
625
698
  feeEligible?: boolean | undefined
626
- /**
627
- * The chain's fee-token set, if resolved (best-effort): flags each row's
628
- * `feeEligible` and backs the `feeEligible` filter. Undefined when the lookup
629
- * failed, in which case `feeEligible` is omitted from rows.
630
- */
631
- feeTokens?: ReadonlySet<string> | undefined
632
699
  /** When true, include the corrected candidate count in response metadata. */
633
700
  includeTotalCount: boolean
634
701
  limit: number
@@ -0,0 +1,34 @@
1
+ import * as QueryBench from '../../../../test/QueryBench.js'
2
+
3
+ const fixtures = {
4
+ 4217: { number: 34_335_551, timestamp: 1_786_485_830 },
5
+ 42431: { number: 30_469_074, timestamp: 1_786_485_831 },
6
+ } as const
7
+
8
+ QueryBench.bench({ name: 'getBlocks', path: '/v1/blocks' })
9
+
10
+ QueryBench.bench({
11
+ name: 'getBlock',
12
+ path: {
13
+ 4217: `/v1/blocks/${fixtures[4217].number}`,
14
+ 42431: `/v1/blocks/${fixtures[42431].number}`,
15
+ },
16
+ })
17
+
18
+ QueryBench.bench({
19
+ chainId: false,
20
+ name: 'getBlockByTimestamp',
21
+ path: {
22
+ 4217: `/chains/4217/blocks/by-timestamp?closest=before&timestamp=${fixtures[4217].timestamp}`,
23
+ 42431: `/chains/42431/blocks/by-timestamp?closest=before&timestamp=${fixtures[42431].timestamp}`,
24
+ },
25
+ })
26
+
27
+ QueryBench.bench({
28
+ chainId: false,
29
+ name: 'getBlockByTimestamp?closest=after',
30
+ path: {
31
+ 4217: `/chains/4217/blocks/by-timestamp?closest=after&timestamp=${fixtures[4217].timestamp}`,
32
+ 42431: `/chains/42431/blocks/by-timestamp?closest=after&timestamp=${fixtures[42431].timestamp}`,
33
+ },
34
+ })
@@ -232,20 +232,26 @@ describe('GET /blocks', () => {
232
232
 
233
233
  describe('GET /blocks/:block (ISO 8601 timestamp)', () => {
234
234
  test('resolves an ISO 8601 timestamp to the first block at or after it', async () => {
235
- expect(recent).toBeDefined()
236
- const recentBlock = await Viem.getClient().getBlock({ blockNumber: recent!.number })
237
- const seconds = Number(recentBlock.timestamp)
238
- const iso = new Date(seconds * 1000).toISOString()
239
235
  const client = TestApp.client()
236
+ // Seed from TIDX rather than the RPC head, which can advance beyond the
237
+ // indexer's timestamp lookup watermark.
238
+ const blocksResponse = await client.v1.blocks.$get({ query: { limit: '5' } }, TestApp.auth)
239
+ const blocks = await TestApp.json(blocksResponse, Blocks.schema.getBlocks.Response)
240
+ const indexed = blocks.data[0]
241
+ expect(blocksResponse.status).toBe(200)
242
+ expect(indexed).toBeDefined()
243
+
240
244
  const response = await client.v1.blocks[':block'].$get(
241
- { param: { block: iso }, query: {} },
245
+ { param: { block: indexed!.timestamp }, query: {} },
242
246
  TestApp.auth,
243
247
  )
244
248
  const body = await TestApp.json(response, Blocks.schema.getBlock.Response)
245
249
  expect(response.status).toBe(200)
246
250
  expect(typeof body.number).toBe('number')
247
251
  // The returned block's timestamp must be >= the requested timestamp.
248
- expect(new Date(body.timestamp).getTime() / 1000).toBeGreaterThanOrEqual(seconds)
252
+ expect(new Date(body.timestamp).getTime()).toBeGreaterThanOrEqual(
253
+ new Date(indexed!.timestamp).getTime(),
254
+ )
249
255
  })
250
256
 
251
257
  test('returns 404 for a far-future timestamp with no indexed block', async () => {