tapimo 0.0.0 → 0.0.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 (422) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +119 -0
  3. package/dist/ApiKey.d.ts +65 -0
  4. package/dist/ApiKey.d.ts.map +1 -0
  5. package/dist/ApiKey.js +105 -0
  6. package/dist/ApiKey.js.map +1 -0
  7. package/dist/ApiKeySource.d.ts +166 -0
  8. package/dist/ApiKeySource.d.ts.map +1 -0
  9. package/dist/ApiKeySource.js +234 -0
  10. package/dist/ApiKeySource.js.map +1 -0
  11. package/dist/App.d.ts +19317 -0
  12. package/dist/App.d.ts.map +1 -0
  13. package/dist/App.js +612 -0
  14. package/dist/App.js.map +1 -0
  15. package/dist/Assets.d.ts +44 -0
  16. package/dist/Assets.d.ts.map +1 -0
  17. package/dist/Assets.js +23 -0
  18. package/dist/Assets.js.map +1 -0
  19. package/dist/Auth.d.ts +2 -0
  20. package/dist/Auth.d.ts.map +1 -0
  21. package/dist/Auth.js +2 -0
  22. package/dist/Auth.js.map +1 -0
  23. package/dist/Cli.d.ts +21 -0
  24. package/dist/Cli.d.ts.map +1 -0
  25. package/dist/Cli.js +13 -0
  26. package/dist/Cli.js.map +1 -0
  27. package/dist/Client.d.ts +20 -0
  28. package/dist/Client.d.ts.map +1 -0
  29. package/dist/Client.js +24 -0
  30. package/dist/Client.js.map +1 -0
  31. package/dist/Metrics.d.ts +70 -0
  32. package/dist/Metrics.d.ts.map +1 -0
  33. package/dist/Metrics.js +91 -0
  34. package/dist/Metrics.js.map +1 -0
  35. package/dist/Scope.d.ts +35 -0
  36. package/dist/Scope.d.ts.map +1 -0
  37. package/dist/Scope.js +38 -0
  38. package/dist/Scope.js.map +1 -0
  39. package/dist/Webhooks.d.ts +79 -0
  40. package/dist/Webhooks.d.ts.map +1 -0
  41. package/dist/Webhooks.js +107 -0
  42. package/dist/Webhooks.js.map +1 -0
  43. package/dist/admin/Access.d.ts +41 -0
  44. package/dist/admin/Access.d.ts.map +1 -0
  45. package/dist/admin/Access.js +75 -0
  46. package/dist/admin/Access.js.map +1 -0
  47. package/dist/admin/App.d.ts +1221 -0
  48. package/dist/admin/App.d.ts.map +1 -0
  49. package/dist/admin/App.js +113 -0
  50. package/dist/admin/App.js.map +1 -0
  51. package/dist/admin/apps/api-keys.d.ts +209 -0
  52. package/dist/admin/apps/api-keys.d.ts.map +1 -0
  53. package/dist/admin/apps/api-keys.js +129 -0
  54. package/dist/admin/apps/api-keys.js.map +1 -0
  55. package/dist/admin/apps/scopes.d.ts +46 -0
  56. package/dist/admin/apps/scopes.d.ts.map +1 -0
  57. package/dist/admin/apps/scopes.js +41 -0
  58. package/dist/admin/apps/scopes.js.map +1 -0
  59. package/dist/admin/apps/verified-tokens.d.ts +1206 -0
  60. package/dist/admin/apps/verified-tokens.d.ts.map +1 -0
  61. package/dist/admin/apps/verified-tokens.js +485 -0
  62. package/dist/admin/apps/verified-tokens.js.map +1 -0
  63. package/dist/admin/index.d.ts +3 -0
  64. package/dist/admin/index.d.ts.map +1 -0
  65. package/dist/admin/index.js +3 -0
  66. package/dist/admin/index.js.map +1 -0
  67. package/dist/admin/ui.generated.d.ts +11 -0
  68. package/dist/admin/ui.generated.d.ts.map +1 -0
  69. package/dist/admin/ui.generated.js +43 -0
  70. package/dist/admin/ui.generated.js.map +1 -0
  71. package/dist/apps/activities.d.ts +18725 -0
  72. package/dist/apps/activities.d.ts.map +1 -0
  73. package/dist/apps/activities.js +3138 -0
  74. package/dist/apps/activities.js.map +1 -0
  75. package/dist/apps/addresses.d.ts +3662 -0
  76. package/dist/apps/addresses.d.ts.map +1 -0
  77. package/dist/apps/addresses.js +22 -0
  78. package/dist/apps/addresses.js.map +1 -0
  79. package/dist/apps/balances.d.ts +342 -0
  80. package/dist/apps/balances.d.ts.map +1 -0
  81. package/dist/apps/balances.js +393 -0
  82. package/dist/apps/balances.js.map +1 -0
  83. package/dist/apps/blocks.d.ts +931 -0
  84. package/dist/apps/blocks.d.ts.map +1 -0
  85. package/dist/apps/blocks.js +689 -0
  86. package/dist/apps/blocks.js.map +1 -0
  87. package/dist/apps/coingecko.d.ts +1053 -0
  88. package/dist/apps/coingecko.d.ts.map +1 -0
  89. package/dist/apps/coingecko.js +1015 -0
  90. package/dist/apps/coingecko.js.map +1 -0
  91. package/dist/apps/exchanges.d.ts +3000 -0
  92. package/dist/apps/exchanges.d.ts.map +1 -0
  93. package/dist/apps/exchanges.js +3570 -0
  94. package/dist/apps/exchanges.js.map +1 -0
  95. package/dist/apps/fee-amm.d.ts +640 -0
  96. package/dist/apps/fee-amm.d.ts.map +1 -0
  97. package/dist/apps/fee-amm.js +815 -0
  98. package/dist/apps/fee-amm.js.map +1 -0
  99. package/dist/apps/indexer.d.ts +116 -0
  100. package/dist/apps/indexer.d.ts.map +1 -0
  101. package/dist/apps/indexer.js +270 -0
  102. package/dist/apps/indexer.js.map +1 -0
  103. package/dist/apps/receipts.d.ts +1438 -0
  104. package/dist/apps/receipts.d.ts.map +1 -0
  105. package/dist/apps/receipts.js +874 -0
  106. package/dist/apps/receipts.js.map +1 -0
  107. package/dist/apps/rpc.d.ts +173 -0
  108. package/dist/apps/rpc.d.ts.map +1 -0
  109. package/dist/apps/rpc.js +191 -0
  110. package/dist/apps/rpc.js.map +1 -0
  111. package/dist/apps/tokenlist.d.ts +192 -0
  112. package/dist/apps/tokenlist.d.ts.map +1 -0
  113. package/dist/apps/tokenlist.js +153 -0
  114. package/dist/apps/tokenlist.js.map +1 -0
  115. package/dist/apps/tokens.d.ts +2246 -0
  116. package/dist/apps/tokens.d.ts.map +1 -0
  117. package/dist/apps/tokens.js +1789 -0
  118. package/dist/apps/tokens.js.map +1 -0
  119. package/dist/apps/transactions.d.ts +2327 -0
  120. package/dist/apps/transactions.d.ts.map +1 -0
  121. package/dist/apps/transactions.js +975 -0
  122. package/dist/apps/transactions.js.map +1 -0
  123. package/dist/apps/transfers.d.ts +589 -0
  124. package/dist/apps/transfers.d.ts.map +1 -0
  125. package/dist/apps/transfers.js +769 -0
  126. package/dist/apps/transfers.js.map +1 -0
  127. package/dist/apps/verified-tokens.d.ts +544 -0
  128. package/dist/apps/verified-tokens.d.ts.map +1 -0
  129. package/dist/apps/verified-tokens.js +234 -0
  130. package/dist/apps/verified-tokens.js.map +1 -0
  131. package/dist/apps/webhooks.d.ts +7684 -0
  132. package/dist/apps/webhooks.d.ts.map +1 -0
  133. package/dist/apps/webhooks.js +2112 -0
  134. package/dist/apps/webhooks.js.map +1 -0
  135. package/dist/cloudflare.d.ts +32 -0
  136. package/dist/cloudflare.d.ts.map +1 -0
  137. package/dist/cloudflare.js +45 -0
  138. package/dist/cloudflare.js.map +1 -0
  139. package/dist/index.d.ts +16 -0
  140. package/dist/index.d.ts.map +1 -0
  141. package/dist/index.js +16 -0
  142. package/dist/index.js.map +1 -0
  143. package/dist/internal/Auth.d.ts +298 -0
  144. package/dist/internal/Auth.d.ts.map +1 -0
  145. package/dist/internal/Auth.js +618 -0
  146. package/dist/internal/Auth.js.map +1 -0
  147. package/dist/internal/Cache.d.ts +74 -0
  148. package/dist/internal/Cache.d.ts.map +1 -0
  149. package/dist/internal/Cache.js +215 -0
  150. package/dist/internal/Cache.js.map +1 -0
  151. package/dist/internal/Cursor.d.ts +79 -0
  152. package/dist/internal/Cursor.d.ts.map +1 -0
  153. package/dist/internal/Cursor.js +116 -0
  154. package/dist/internal/Cursor.js.map +1 -0
  155. package/dist/internal/EdgeCache.d.ts +59 -0
  156. package/dist/internal/EdgeCache.d.ts.map +1 -0
  157. package/dist/internal/EdgeCache.js +174 -0
  158. package/dist/internal/EdgeCache.js.map +1 -0
  159. package/dist/internal/Log.d.ts +104 -0
  160. package/dist/internal/Log.d.ts.map +1 -0
  161. package/dist/internal/Log.js +118 -0
  162. package/dist/internal/Log.js.map +1 -0
  163. package/dist/internal/MetricSink.d.ts +65 -0
  164. package/dist/internal/MetricSink.d.ts.map +1 -0
  165. package/dist/internal/MetricSink.js +146 -0
  166. package/dist/internal/MetricSink.js.map +1 -0
  167. package/dist/internal/Mpp.d.ts +71 -0
  168. package/dist/internal/Mpp.d.ts.map +1 -0
  169. package/dist/internal/Mpp.js +105 -0
  170. package/dist/internal/Mpp.js.map +1 -0
  171. package/dist/internal/OpenApi.d.ts +313 -0
  172. package/dist/internal/OpenApi.d.ts.map +1 -0
  173. package/dist/internal/OpenApi.fonts.d.ts +2 -0
  174. package/dist/internal/OpenApi.fonts.d.ts.map +1 -0
  175. package/dist/internal/OpenApi.fonts.js +39 -0
  176. package/dist/internal/OpenApi.fonts.js.map +1 -0
  177. package/dist/internal/OpenApi.js +335 -0
  178. package/dist/internal/OpenApi.js.map +1 -0
  179. package/dist/internal/OpenApi.logo.d.ts +6 -0
  180. package/dist/internal/OpenApi.logo.d.ts.map +1 -0
  181. package/dist/internal/OpenApi.logo.js +19 -0
  182. package/dist/internal/OpenApi.logo.js.map +1 -0
  183. package/dist/internal/Path.d.ts +5 -0
  184. package/dist/internal/Path.d.ts.map +1 -0
  185. package/dist/internal/Path.js +16 -0
  186. package/dist/internal/Path.js.map +1 -0
  187. package/dist/internal/RateLimit.d.ts +60 -0
  188. package/dist/internal/RateLimit.d.ts.map +1 -0
  189. package/dist/internal/RateLimit.js +48 -0
  190. package/dist/internal/RateLimit.js.map +1 -0
  191. package/dist/internal/Response.d.ts +132 -0
  192. package/dist/internal/Response.d.ts.map +1 -0
  193. package/dist/internal/Response.js +72 -0
  194. package/dist/internal/Response.js.map +1 -0
  195. package/dist/internal/Schema.d.ts +257 -0
  196. package/dist/internal/Schema.d.ts.map +1 -0
  197. package/dist/internal/Schema.js +360 -0
  198. package/dist/internal/Schema.js.map +1 -0
  199. package/dist/internal/Store.d.ts +204 -0
  200. package/dist/internal/Store.d.ts.map +1 -0
  201. package/dist/internal/Store.js +291 -0
  202. package/dist/internal/Store.js.map +1 -0
  203. package/dist/internal/Tidx.d.ts +120 -0
  204. package/dist/internal/Tidx.d.ts.map +1 -0
  205. package/dist/internal/Tidx.js +156 -0
  206. package/dist/internal/Tidx.js.map +1 -0
  207. package/dist/internal/Timing.d.ts +22 -0
  208. package/dist/internal/Timing.d.ts.map +1 -0
  209. package/dist/internal/Timing.js +32 -0
  210. package/dist/internal/Timing.js.map +1 -0
  211. package/dist/internal/Ttl.d.ts +25 -0
  212. package/dist/internal/Ttl.d.ts.map +1 -0
  213. package/dist/internal/Ttl.js +49 -0
  214. package/dist/internal/Ttl.js.map +1 -0
  215. package/dist/internal/Value.d.ts +47 -0
  216. package/dist/internal/Value.d.ts.map +1 -0
  217. package/dist/internal/Value.js +122 -0
  218. package/dist/internal/Value.js.map +1 -0
  219. package/dist/internal/VerifiedTokens.d.ts +228 -0
  220. package/dist/internal/VerifiedTokens.d.ts.map +1 -0
  221. package/dist/internal/VerifiedTokens.js +450 -0
  222. package/dist/internal/VerifiedTokens.js.map +1 -0
  223. package/dist/internal/Viem.d.ts +2082 -0
  224. package/dist/internal/Viem.d.ts.map +1 -0
  225. package/dist/internal/Viem.js +63 -0
  226. package/dist/internal/Viem.js.map +1 -0
  227. package/dist/internal/WebhookDestination.d.ts +198 -0
  228. package/dist/internal/WebhookDestination.d.ts.map +1 -0
  229. package/dist/internal/WebhookDestination.js +170 -0
  230. package/dist/internal/WebhookDestination.js.map +1 -0
  231. package/dist/internal/Webhooks.d.ts +433 -0
  232. package/dist/internal/Webhooks.d.ts.map +1 -0
  233. package/dist/internal/Webhooks.js +489 -0
  234. package/dist/internal/Webhooks.js.map +1 -0
  235. package/dist/internal/webhookDestinations/betterStack.d.ts +26 -0
  236. package/dist/internal/webhookDestinations/betterStack.d.ts.map +1 -0
  237. package/dist/internal/webhookDestinations/betterStack.js +76 -0
  238. package/dist/internal/webhookDestinations/betterStack.js.map +1 -0
  239. package/dist/internal/webhookDestinations/slack.d.ts +59 -0
  240. package/dist/internal/webhookDestinations/slack.d.ts.map +1 -0
  241. package/dist/internal/webhookDestinations/slack.js +422 -0
  242. package/dist/internal/webhookDestinations/slack.js.map +1 -0
  243. package/dist/internal/webhookDestinations/url.d.ts +17 -0
  244. package/dist/internal/webhookDestinations/url.d.ts.map +1 -0
  245. package/dist/internal/webhookDestinations/url.js +130 -0
  246. package/dist/internal/webhookDestinations/url.js.map +1 -0
  247. package/dist/openrpc.json +5302 -0
  248. package/package.json +46 -4
  249. package/seeds/31318/icons/0x20c0000000000000000000000000000000000000.svg +1 -0
  250. package/seeds/31318/icons/0x20c0000000000000000000000000000000000001.svg +1 -0
  251. package/seeds/31318/icons/0x20c0000000000000000000000000000000000002.svg +1 -0
  252. package/seeds/31318/icons/0x20c0000000000000000000000000000000000003.svg +1 -0
  253. package/seeds/31318/verified-tokens.json +30 -0
  254. package/seeds/4217/icons/0x20c0000000000000000000000000000000000000.svg +25 -0
  255. package/seeds/4217/icons/0x20c000000000000000000000048c8f36df1c9a4a.svg +1 -0
  256. package/seeds/4217/icons/0x20c0000000000000000000000520792dcccccccc.svg +1 -0
  257. package/seeds/4217/icons/0x20c0000000000000000000000a6da882d075a4c3.svg +4 -0
  258. package/seeds/4217/icons/0x20c000000000000000000000111111111e910f0f.svg +30 -0
  259. package/seeds/4217/icons/0x20c00000000000000000000014f22ca97301eb73.svg +1 -0
  260. package/seeds/4217/icons/0x20c0000000000000000000001621e21f71cf12fb.svg +1 -0
  261. package/seeds/4217/icons/0x20c0000000000000000000002f52d5cc21a3207b.svg +1 -0
  262. package/seeds/4217/icons/0x20c0000000000000000000003158081efd85bfc2.svg +11 -0
  263. package/seeds/4217/icons/0x20c0000000000000000000003554d28269e0f3c2.svg +1 -0
  264. package/seeds/4217/icons/0x20c000000000000000000000383a23bacb546ab9.svg +1 -0
  265. package/seeds/4217/icons/0x20c00000000000000000000042109aef2f8b28e1.png +0 -0
  266. package/seeds/4217/icons/0x20c0000000000000000000005c0bac7cef389a11.svg +1 -0
  267. package/seeds/4217/icons/0x20c0000000000000000000006fd9a167923ba194.png +0 -0
  268. package/seeds/4217/icons/0x20c0000000000000000000007f7ba549dd0251b9.svg +1 -0
  269. package/seeds/4217/icons/0x20c0000000000000000000008191667423f70e67.svg +5 -0
  270. package/seeds/4217/icons/0x20c0000000000000000000008ee4fcff88888888.svg +1 -0
  271. package/seeds/4217/icons/0x20c0000000000000000000009a4a4b17e0dc6651.svg +1 -0
  272. package/seeds/4217/icons/0x20c000000000000000000000ab02d39df30bd17e.svg +1 -0
  273. package/seeds/4217/icons/0x20c000000000000000000000ae247a1130450f09.svg +1 -0
  274. package/seeds/4217/icons/0x20c000000000000000000000aeed2ec36a54d0e5.svg +1 -0
  275. package/seeds/4217/icons/0x20c000000000000000000000b9537d11c60e8b50.svg +1 -0
  276. package/seeds/4217/icons/0x20c000000000000000000000bd95bfb69fbe6ce3.svg +1 -0
  277. package/seeds/4217/icons/0x20c000000000000000000000c412ec89d0c08be5.svg +1 -0
  278. package/seeds/4217/icons/0x20c000000000000000000000f047dd7018e50367.png +0 -0
  279. package/seeds/4217/verified-tokens.json +177 -0
  280. package/seeds/42431/icons/0x20c0000000000000000000000000000000000000.svg +25 -0
  281. package/seeds/42431/icons/0x20c0000000000000000000000000000000000001.svg +1 -0
  282. package/seeds/42431/icons/0x20c0000000000000000000000000000000000002.svg +1 -0
  283. package/seeds/42431/icons/0x20c0000000000000000000000000000000000003.svg +1 -0
  284. package/seeds/42431/icons/0x20c0000000000000000000009e8d7eb59b783726.svg +1 -0
  285. package/seeds/42431/icons/0x20c000000000000000000000d72572838bbee59c.svg +1 -0
  286. package/seeds/42431/verified-tokens.json +44 -0
  287. package/seeds/openrpc/README.md +27 -0
  288. package/seeds/openrpc/execution-apis.json +8989 -0
  289. package/src/ApiKey.test.ts +91 -0
  290. package/src/ApiKey.ts +122 -0
  291. package/src/ApiKeySource.test-d.ts +11 -0
  292. package/src/ApiKeySource.test.ts +295 -0
  293. package/src/ApiKeySource.ts +303 -0
  294. package/src/App.test-d.ts +38 -0
  295. package/src/App.test.ts +65927 -0
  296. package/src/App.ts +855 -0
  297. package/src/Assets.ts +58 -0
  298. package/src/Auth.ts +1 -0
  299. package/src/Cli.test.ts +81 -0
  300. package/src/Cli.ts +38 -0
  301. package/src/Client.test-d.ts +1438 -0
  302. package/src/Client.test.ts +134 -0
  303. package/src/Client.ts +45 -0
  304. package/src/Metrics.ts +154 -0
  305. package/src/Scope.ts +50 -0
  306. package/src/Webhooks.test.ts +49 -0
  307. package/src/Webhooks.ts +172 -0
  308. package/src/admin/Access.test.ts +130 -0
  309. package/src/admin/Access.ts +87 -0
  310. package/src/admin/App.test.ts +225 -0
  311. package/src/admin/App.ts +216 -0
  312. package/src/admin/apps/api-keys.test.ts +156 -0
  313. package/src/admin/apps/api-keys.ts +157 -0
  314. package/src/admin/apps/scopes.test.ts +52 -0
  315. package/src/admin/apps/scopes.ts +47 -0
  316. package/src/admin/apps/verified-tokens.test.ts +415 -0
  317. package/src/admin/apps/verified-tokens.ts +566 -0
  318. package/src/admin/index.ts +2 -0
  319. package/src/admin/ui/plugin.ts +135 -0
  320. package/src/admin/ui/src/components/Checkbox.tsx +22 -0
  321. package/src/admin/ui/src/components/Dialog.tsx +32 -0
  322. package/src/admin/ui/src/components/MobileProfile.tsx +31 -0
  323. package/src/admin/ui/src/components/Page.tsx +53 -0
  324. package/src/admin/ui/src/components/TempoLogo.tsx +37 -0
  325. package/src/admin/ui/src/fonts/Pilat-Demi.woff2 +0 -0
  326. package/src/admin/ui/src/fonts/Pilat-Regular.woff2 +0 -0
  327. package/src/admin/ui/src/lib/api-keys.ts +63 -0
  328. package/src/admin/ui/src/lib/client.ts +15 -0
  329. package/src/admin/ui/src/lib/config.ts +57 -0
  330. package/src/admin/ui/src/lib/query-client.ts +26 -0
  331. package/src/admin/ui/src/lib/scopes.ts +25 -0
  332. package/src/admin/ui/src/lib/verified-tokens.ts +89 -0
  333. package/src/admin/ui/src/lib/wagmi.ts +18 -0
  334. package/src/admin/ui/src/routeTree.gen.ts +104 -0
  335. package/src/admin/ui/src/router.tsx +25 -0
  336. package/src/admin/ui/src/routes/__root.tsx +219 -0
  337. package/src/admin/ui/src/routes/api-keys.tsx +329 -0
  338. package/src/admin/ui/src/routes/index.tsx +7 -0
  339. package/src/admin/ui/src/routes/verified-tokens.tsx +331 -0
  340. package/src/admin/ui/src/styles.css +20 -0
  341. package/src/admin/ui/tsconfig.json +16 -0
  342. package/src/admin/ui/vite.config.ts +29 -0
  343. package/src/admin/ui.generated.ts +60 -0
  344. package/src/apps/activities.test.ts +1020 -0
  345. package/src/apps/activities.ts +3934 -0
  346. package/src/apps/addresses.ts +24 -0
  347. package/src/apps/balances.test.ts +274 -0
  348. package/src/apps/balances.ts +533 -0
  349. package/src/apps/blocks.test.ts +385 -0
  350. package/src/apps/blocks.ts +896 -0
  351. package/src/apps/coingecko.test.ts +218 -0
  352. package/src/apps/coingecko.ts +1383 -0
  353. package/src/apps/exchanges.test.ts +958 -0
  354. package/src/apps/exchanges.ts +4773 -0
  355. package/src/apps/fee-amm.test.ts +321 -0
  356. package/src/apps/fee-amm.ts +1121 -0
  357. package/src/apps/indexer.test.ts +153 -0
  358. package/src/apps/indexer.ts +330 -0
  359. package/src/apps/receipts.test.ts +285 -0
  360. package/src/apps/receipts.ts +1193 -0
  361. package/src/apps/rpc.test.ts +302 -0
  362. package/src/apps/rpc.ts +222 -0
  363. package/src/apps/tokenlist.test.ts +80 -0
  364. package/src/apps/tokenlist.ts +207 -0
  365. package/src/apps/tokens.test.ts +1062 -0
  366. package/src/apps/tokens.ts +2292 -0
  367. package/src/apps/transactions.test.ts +408 -0
  368. package/src/apps/transactions.ts +1363 -0
  369. package/src/apps/transfers.test.ts +371 -0
  370. package/src/apps/transfers.ts +1116 -0
  371. package/src/apps/verified-tokens.test.ts +114 -0
  372. package/src/apps/verified-tokens.ts +295 -0
  373. package/src/apps/webhooks.test.ts +1937 -0
  374. package/src/apps/webhooks.ts +2926 -0
  375. package/src/cloudflare.ts +55 -0
  376. package/src/index.ts +15 -0
  377. package/src/internal/Auth.test.ts +980 -0
  378. package/src/internal/Auth.ts +1012 -0
  379. package/src/internal/Cache.test.ts +240 -0
  380. package/src/internal/Cache.ts +262 -0
  381. package/src/internal/Cursor.test.ts +100 -0
  382. package/src/internal/Cursor.ts +149 -0
  383. package/src/internal/EdgeCache.test.ts +234 -0
  384. package/src/internal/EdgeCache.ts +224 -0
  385. package/src/internal/Errors.ts +14 -0
  386. package/src/internal/Log.test.ts +244 -0
  387. package/src/internal/Log.ts +216 -0
  388. package/src/internal/MetricSink.ts +220 -0
  389. package/src/internal/Mpp.test.ts +64 -0
  390. package/src/internal/Mpp.ts +141 -0
  391. package/src/internal/OpenApi.fonts.ts +38 -0
  392. package/src/internal/OpenApi.logo.ts +18 -0
  393. package/src/internal/OpenApi.ts +488 -0
  394. package/src/internal/Path.test.ts +14 -0
  395. package/src/internal/Path.ts +15 -0
  396. package/src/internal/RateLimit.test.ts +93 -0
  397. package/src/internal/RateLimit.ts +93 -0
  398. package/src/internal/Response.ts +131 -0
  399. package/src/internal/Schema.test.ts +34 -0
  400. package/src/internal/Schema.ts +523 -0
  401. package/src/internal/Store.test.ts +545 -0
  402. package/src/internal/Store.ts +526 -0
  403. package/src/internal/Tidx.local.test.ts +33 -0
  404. package/src/internal/Tidx.test.ts +294 -0
  405. package/src/internal/Tidx.ts +238 -0
  406. package/src/internal/Timing.test.ts +22 -0
  407. package/src/internal/Timing.ts +62 -0
  408. package/src/internal/Ttl.test.ts +18 -0
  409. package/src/internal/Ttl.ts +59 -0
  410. package/src/internal/Value.ts +114 -0
  411. package/src/internal/VerifiedTokens.test.ts +333 -0
  412. package/src/internal/VerifiedTokens.ts +645 -0
  413. package/src/internal/Viem.test.ts +115 -0
  414. package/src/internal/Viem.ts +165 -0
  415. package/src/internal/WebhookDestination.test.ts +648 -0
  416. package/src/internal/WebhookDestination.ts +331 -0
  417. package/src/internal/Webhooks.test.ts +668 -0
  418. package/src/internal/Webhooks.ts +940 -0
  419. package/src/internal/webhookDestinations/betterStack.ts +91 -0
  420. package/src/internal/webhookDestinations/slack.ts +496 -0
  421. package/src/internal/webhookDestinations/url.ts +128 -0
  422. package/src/openrpc.json +5302 -0
@@ -0,0 +1,4773 @@
1
+ import { Hono, type Context } from 'hono'
2
+ import { AbiEvent, type Hex } from 'ox'
3
+ import {
4
+ type Address,
5
+ BaseError,
6
+ ContractFunctionRevertedError,
7
+ decodeFunctionData,
8
+ parseAbi,
9
+ } from 'viem'
10
+ import { Actions, Addresses, Tick } from 'viem/tempo'
11
+ import * as z from 'zod/mini'
12
+
13
+ import type * as App from '../App.js'
14
+ import * as Auth from '../internal/Auth.js'
15
+ import * as Cache from '../internal/Cache.js'
16
+ import * as Cursor from '../internal/Cursor.js'
17
+ import * as OpenApi from '../internal/OpenApi.js'
18
+ import * as Response from '../internal/Response.js'
19
+ import * as Schema from '../internal/Schema.js'
20
+ import * as Store from '../internal/Store.js'
21
+ import * as Timing from '../internal/Timing.js'
22
+ import * as Ttl from '../internal/Ttl.js'
23
+ import * as Value from '../internal/Value.js'
24
+ import * as VerifiedTokens from '../internal/VerifiedTokens.js'
25
+ import * as Tokens from './tokens.js'
26
+
27
+ // Stablecoin DEX precompile. Pair lifecycle is announced by `PairCreated` from
28
+ // this address; the indexer pre-decodes those into the `dex_pairs` table.
29
+ const stablecoinDex = Addresses.stablecoinDex.toLowerCase()
30
+
31
+ // `OrderCancelled(uint128 orderId)` — emitted when a maker cancels a resting
32
+ // order. Used by the orders endpoint to exclude cancelled orders from the
33
+ // resting set.
34
+ const orderCancelledSignature = 'event OrderCancelled(uint128 indexed orderId)'
35
+
36
+ // Topic0 hashes of the DEX order-state events. The swap feed resolves each
37
+ // fill's `(token, isBid, tick)` point-in-time from the raw `logs` event
38
+ // stream instead of the decoded `dex_orders` table: T5+ flip orders keep
39
+ // their `orderId`, mutate `(isBid, tick)` on every flip, and emit only
40
+ // `OrderFlipped` (no `OrderPlaced`), so `dex_orders` is both incomplete and
41
+ // stale for them.
42
+ const orderPlacedTopic = AbiEvent.getSelector(
43
+ 'event OrderPlaced(uint128 indexed orderId, address indexed maker, address indexed token, uint128 amount, bool isBid, int16 tick, bool isFlipOrder, int16 flipTick)',
44
+ )
45
+ const orderFlippedTopic = AbiEvent.getSelector(
46
+ 'event OrderFlipped(uint128 indexed orderId, address indexed maker, address indexed token, uint128 amount, bool isBid, int16 tick, int16 flipTick)',
47
+ )
48
+
49
+ // Block-range window for every `dex_fills` GROUP BY batch in the swaps feed.
50
+ // An unbounded grouped scan aggregates the entire fill history just to emit the
51
+ // top `limit + 1` groups, which TIDX rejects outright (HTTP 422) once the table
52
+ // grows large enough — so the scan is *always* bounded to a block window and
53
+ // the loop slides the window across batches until the page fills or the feed is
54
+ // exhausted. The window must comfortably hold a max-`limit` page (200 groups):
55
+ // 100k blocks (~28h) holds ~180k fills on Moderato. A group is keyed by
56
+ // `(block_num, tx_hash, taker)`, so a block-range bound never splits one group
57
+ // across windows; the value only tunes how many round-trips the sweep takes.
58
+ const swapWindowBlocks = 100_000
59
+
60
+ // Swap entrypoints of the stablecoin DEX. A swap's `mode` (exact-source vs
61
+ // exact-destination) is not in the events — it is recovered by decoding the
62
+ // transaction calldata (direct calls and AA type-`0x76` inner calls) against
63
+ // these signatures.
64
+ const swapAbi = parseAbi([
65
+ 'function swapExactAmountIn(address tokenIn, address tokenOut, uint128 amountIn, uint128 minAmountOut) returns (uint128)',
66
+ 'function swapExactAmountOut(address tokenIn, address tokenOut, uint128 amountOut, uint128 maxAmountIn) returns (uint128)',
67
+ ])
68
+
69
+ // Example pair base token used by the OpenAPI route examples. Picked so the
70
+ // rendered URL addresses a realistic, well-known pair (USDC.e, quoted in
71
+ // pathUSD on-chain). Uses a fresh `Schema.tokenAddress(...)` so the example
72
+ // reaches the pipe input that path-param OpenAPI generation reads from.
73
+ const exampleBase = '0x20c000000000000000000000b9537d11c60e8b50' as const
74
+
75
+ /**
76
+ * Tick spacing enforced by the stablecoin DEX precompile: orders must be
77
+ * placed at ticks divisible by 10 (a 1 bp price grid). Walking the depth at
78
+ * this stride probes every valid level without wasting RPC calls on slots the
79
+ * DEX rejects at order placement. Not currently exported by `ox/tempo`; the
80
+ * upstream invariant is documented in the Tempo DEX spec.
81
+ */
82
+ const tickSpacing = 10
83
+
84
+ /**
85
+ * Default number of non-empty price levels returned per side when callers
86
+ * omit `levels`. Wide enough to cover a typical depth chart's foreground while
87
+ * keeping the payload compact; well-traded pairs that cluster liquidity near
88
+ * peg fit comfortably inside this cap.
89
+ */
90
+ const defaultDepthLevels = 50
91
+
92
+ /**
93
+ * Hard ceiling on `levels`. The DEX tick range (`Tick.minTick`..`Tick.maxTick`)
94
+ * exposes at most 201 valid positions per side at `tickSpacing=10`, so 200 is
95
+ * an effective full-book cap and bounds the per-request multicall payload.
96
+ */
97
+ const maxDepthLevels = 200
98
+
99
+ /**
100
+ * Maximum number of buckets a single OHLC request may produce. Combined with
101
+ * the OHLC `Interval` and `Window` enums this bounds the aggregation work
102
+ * and the response size: e.g. `interval=1m, window=24h` produces 1440
103
+ * buckets and is rejected as `query_invalid`. Hoisted above the schema
104
+ * namespace because the OHLC `Query` refine references it at validation time.
105
+ */
106
+ const maxBuckets = 500
107
+
108
+ /** Converts the OHLC `interval` enum to a millisecond duration. */
109
+ function intervalMs(interval: '1m' | '5m' | '15m' | '1h' | '4h' | '1d'): number {
110
+ switch (interval) {
111
+ case '1m':
112
+ return 60 * 1000
113
+ case '5m':
114
+ return 5 * 60 * 1000
115
+ case '15m':
116
+ return 15 * 60 * 1000
117
+ case '1h':
118
+ return 60 * 60 * 1000
119
+ case '4h':
120
+ return 4 * 60 * 60 * 1000
121
+ case '1d':
122
+ return 24 * 60 * 60 * 1000
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Formats an ISO-8601 datetime string (e.g. `2026-06-04T12:00:00Z`) into the
128
+ * `YYYY-MM-DD HH:MM:SS` form ClickHouse accepts for `DateTime64(3, 'UTC')`
129
+ * comparisons. The ISO-Z form rejects with a conversion error on ClickHouse
130
+ * `DateTime64`. Input is trusted (validated upstream by Zod / produced by
131
+ * `Date.toISOString()` internally).
132
+ */
133
+ function formatClickHouseDateTime(value: string): string {
134
+ return value
135
+ .replace('T', ' ')
136
+ .replace(/\.\d+Z$/, '')
137
+ .replace(/Z$/, '')
138
+ }
139
+
140
+ /** Converts the OHLC `window` enum to a millisecond duration. */
141
+ function windowMs(window: '1h' | '24h' | '7d' | '30d'): number {
142
+ switch (window) {
143
+ case '1h':
144
+ return 60 * 60 * 1000
145
+ case '24h':
146
+ return 24 * 60 * 60 * 1000
147
+ case '7d':
148
+ return 7 * 24 * 60 * 60 * 1000
149
+ case '30d':
150
+ return 30 * 24 * 60 * 60 * 1000
151
+ }
152
+ }
153
+
154
+ /** Zod schemas owned by the exchange handlers. */
155
+ export namespace schema {
156
+ /**
157
+ * A token in a trading pair: its contract address, plus the trimmed token
158
+ * reference spread in when requested via `include=tokens`. Reference fields
159
+ * are absent on the base (no-`include`) response and best-effort when
160
+ * requested (a token whose metadata is unavailable keeps just `address`).
161
+ */
162
+ export const PairToken = Schema.describe(
163
+ z.object({
164
+ address: Schema.TokenAddress.check(
165
+ z.describe(
166
+ 'TIP-20 token contract address — the `0x`-prefixed 20-byte identifier for this token.',
167
+ ),
168
+ z.meta({ examples: ['0x20c0000000000000000000008f5425160ebe5525'] }),
169
+ ),
170
+ currency: z
171
+ .optional(z.string())
172
+ .check(
173
+ z.describe('Human-readable currency code for the token, when known.'),
174
+ z.meta({ examples: ['USD'] }),
175
+ ),
176
+ decimals: z
177
+ .optional(z.number().check(z.int(), z.nonnegative()))
178
+ .check(
179
+ z.describe(
180
+ 'Number of decimal places the token uses; stablecoins on Tempo typically use 6.',
181
+ ),
182
+ z.meta({ examples: [6] }),
183
+ ),
184
+ logoUri: z.optional(z.string()).check(
185
+ z.describe('URL for the token logo SVG, when one is available.'),
186
+ z.meta({
187
+ examples: [
188
+ 'https://api.tempo.xyz/data/4217/icons/0x20c0000000000000000000008f5425160ebe5525.svg',
189
+ ],
190
+ }),
191
+ ),
192
+ name: z
193
+ .optional(z.string())
194
+ .check(
195
+ z.describe('Human-readable token name, such as `USD Coin`.'),
196
+ z.meta({ examples: ['USD Coin'] }),
197
+ ),
198
+ symbol: z
199
+ .optional(z.string())
200
+ .check(
201
+ z.describe('Short token ticker symbol, such as `USDC`.'),
202
+ z.meta({ examples: ['USDC'] }),
203
+ ),
204
+ verified: z
205
+ .optional(z.boolean())
206
+ .check(
207
+ z.describe('Whether Tempo has verified this token metadata.'),
208
+ z.meta({ examples: [true] }),
209
+ ),
210
+ }),
211
+ 'One side of a trading pair (with metadata when requested via `include=tokens`).',
212
+ )
213
+
214
+ /** Schemas for the getPairs operation. */
215
+ export namespace getPairs {
216
+ /**
217
+ * Optional resources for the pair list: the per-row `tokens` embed plus the
218
+ * response-wide capped `totalCount`. The detail route keeps its own
219
+ * `tokens`-only {@link getPair.Include}.
220
+ */
221
+ export const Include = z
222
+ .enum(['tokens', 'totalCount'])
223
+ .check(
224
+ z.describe(
225
+ 'Extra resources you can request with `include`, such as token metadata or a total count.',
226
+ ),
227
+ )
228
+
229
+ /**
230
+ * Parses a comma-separated `include` query value into a list of optional
231
+ * resources to embed. Extra lookups (e.g. token metadata) only run when
232
+ * explicitly requested, keeping the base pair page fast.
233
+ */
234
+ export const includeQuery = Schema.includeQuery(
235
+ Include,
236
+ 'Comma-separated optional resources to embed, e.g. `tokens,totalCount`.',
237
+ )
238
+
239
+ /** Query parameters for trading pair list requests. */
240
+ export const Query = z
241
+ .strictObject({
242
+ chainId: Schema.ChainIdQuery,
243
+ cursor: Schema.Cursor,
244
+ include: includeQuery,
245
+ limit: Schema.Limit,
246
+ order: Schema.Order,
247
+ page: Schema.Page,
248
+ sort: z
249
+ ._default(z.enum(['created', 'liquidity']), 'created')
250
+ .check(
251
+ z.describe(
252
+ 'Sort key: `created` orders by when the pair was created, while `liquidity` ranks by the DEX-held base-token balance as a practical liquidity signal.',
253
+ ),
254
+ z.meta({ examples: ['created'] }),
255
+ ),
256
+ })
257
+ .check(
258
+ ...Schema.pageChecks(),
259
+ z.describe('Filters, pagination, and sorting options for listing exchange trading pairs.'),
260
+ )
261
+
262
+ /** A single trading pair on the stablecoin DEX. */
263
+ export const Pair = z
264
+ .object({
265
+ base: PairToken.check(
266
+ z.describe('Base token — the asset whose price is quoted in the pair.'),
267
+ ),
268
+ blockNumber: z
269
+ .number()
270
+ .check(
271
+ z.int(),
272
+ z.nonnegative(),
273
+ z.describe('Block number where the pair was created.'),
274
+ z.meta({ examples: [23456789] }),
275
+ ),
276
+ id: Schema.Hash.check(
277
+ z.describe('Stable API id for this pair; it is the same value as the onchain pair key.'),
278
+ ),
279
+ key: Schema.Hash.check(
280
+ z.describe('Stable onchain pair identifier returned by the DEX precompile as `pairKey`.'),
281
+ ),
282
+ liquidity: z
283
+ .optional(
284
+ z
285
+ .string()
286
+ .check(
287
+ z.regex(/^\d+$/),
288
+ z.describe(
289
+ 'Liquidity signal for the pair: the base-token balance held by the DEX, returned as a decimal integer string in the token’s smallest units. Present only when `sort=liquidity`.',
290
+ ),
291
+ ),
292
+ )
293
+ .check(
294
+ z.describe(
295
+ 'Liquidity signal for the pair, present only when you request `sort=liquidity`.',
296
+ ),
297
+ z.meta({ examples: ['1000000'] }),
298
+ ),
299
+ quote: PairToken.check(z.describe('Quote token — the asset used to price the base token.')),
300
+ timestamp: z.iso
301
+ .datetime()
302
+ .check(
303
+ z.describe('Block timestamp when the pair was created.'),
304
+ z.meta({ examples: ['2024-01-01T00:00:00Z'] }),
305
+ ),
306
+ transactionHash: Schema.Hash.check(
307
+ z.describe('Transaction hash for the transaction that created the pair.'),
308
+ ),
309
+ })
310
+ .check(z.describe('One trading pair on Tempo’s built-in stablecoin exchange.'))
311
+
312
+ /** Page of trading pairs on the stablecoin DEX, ordered by creation. */
313
+ export const Response = z
314
+ .object({
315
+ data: z.array(Pair).check(z.describe('Trading pairs returned on this page.')),
316
+ meta: z
317
+ .optional(Schema.CountMeta)
318
+ .check(
319
+ z.describe('Response-level resources requested with `include`, such as `totalCount`.'),
320
+ ),
321
+ nextCursor: Schema.NextCursor,
322
+ })
323
+ .check(
324
+ z.describe('A paginated list of trading pairs on Tempo’s built-in stablecoin exchange.'),
325
+ )
326
+ }
327
+
328
+ /** Schemas for the getPair operation (single trading pair detail). */
329
+ export namespace getPair {
330
+ /**
331
+ * Path parameter for pair detail requests. On-chain, a pair's quote is
332
+ * intrinsic to its base token (the precompile resolves it from the TIP-20's
333
+ * `quote_token()`), so the base address alone identifies the pair.
334
+ */
335
+ export const Params = z
336
+ .object({
337
+ base: Schema.tokenAddress(exampleBase).check(
338
+ z.describe('Base token address for the trading pair.'),
339
+ ),
340
+ })
341
+ .check(z.describe('Path parameters for looking up a trading pair.'))
342
+
343
+ /** Optional related resource that callers opt into via `include`. */
344
+ export const Include = z
345
+ .enum(['tokens'])
346
+ .check(z.describe('Related resource you can opt into with `include`.'))
347
+
348
+ /**
349
+ * Parses a comma-separated `include` query value into a list of optional
350
+ * resources to embed.
351
+ */
352
+ export const includeQuery = Schema.includeQuery(
353
+ Include,
354
+ 'Comma-separated optional resources to embed, e.g. `tokens`.',
355
+ )
356
+
357
+ /** Query parameters for pair detail requests. */
358
+ export const Query = z
359
+ .strictObject({
360
+ chainId: Schema.ChainIdQuery,
361
+ include: includeQuery,
362
+ })
363
+ .check(z.describe('Query options for fetching one trading pair.'))
364
+
365
+ /** A single trading pair, identical in shape to `GET /exchange/pairs` rows. */
366
+ export const Response = getPairs.Pair
367
+ }
368
+
369
+ /** Schemas for the getSwaps operation (the swap feed, optionally pair-scoped). */
370
+ export namespace getSwaps {
371
+ /**
372
+ * Optional related resource that callers opt into via `include`. Currently
373
+ * only `tokens` (embeds metadata for both pair tokens on each row's
374
+ * `sourceToken`/`destinationToken`).
375
+ */
376
+ export const Include = z
377
+ .enum(['tokens'])
378
+ .check(z.describe('Related resource you can opt into with `include`.'))
379
+
380
+ /**
381
+ * Parses a comma-separated `include` query value into a list of optional
382
+ * resources to embed. Extra lookups (e.g. token metadata) only run when
383
+ * explicitly requested, keeping the base swap feed fast.
384
+ */
385
+ export const includeQuery = Schema.includeQuery(
386
+ Include,
387
+ 'Comma-separated optional resources to embed, e.g. `tokens`.',
388
+ )
389
+
390
+ /** Query parameters for swap feed requests. */
391
+ export const Query = z
392
+ .strictObject({
393
+ 'blockNumber.from': Schema.blockNumberBound('swaps', 'from'),
394
+ 'blockNumber.to': Schema.blockNumberBound('swaps', 'to'),
395
+ chainId: Schema.ChainIdQuery,
396
+ cursor: Schema.Cursor,
397
+ include: includeQuery,
398
+ limit: Schema.Limit,
399
+ maker: z
400
+ .optional(Schema.Address)
401
+ .check(
402
+ z.describe(
403
+ 'Only include swaps that filled against an order owned by this maker address.',
404
+ ),
405
+ ),
406
+ order: Schema.Order,
407
+ participant: z
408
+ .optional(Schema.Address)
409
+ .check(
410
+ z.describe(
411
+ 'Only include swaps where this address participated as the taker or as a maker whose order was filled.',
412
+ ),
413
+ ),
414
+ taker: z
415
+ .optional(Schema.Address)
416
+ .check(z.describe('Only include swaps initiated by this taker address.')),
417
+ 'timestamp.from': Schema.timestampBound('swaps', 'from'),
418
+ 'timestamp.to': Schema.timestampBound('swaps', 'to'),
419
+ transactionHash: z
420
+ .optional(Schema.Hash)
421
+ .check(z.describe('Only include swaps included in this transaction hash.')),
422
+ })
423
+ .check(
424
+ z.describe(
425
+ 'Query parameters for swap feed requests. Cursor-paginated only — a batched ' +
426
+ 'transaction expands one fill group into a variable number of swap rows, so a ' +
427
+ 'positional `page` has no stable meaning on this feed.',
428
+ ),
429
+ )
430
+
431
+ /**
432
+ * One side of a swap: a `PairToken` (address plus optional metadata when
433
+ * requested via `include=tokens`) annotated with the `amount` that moved
434
+ * on this side, as a decimal integer string in base units.
435
+ */
436
+ export const SwapSide = z
437
+ .extend(PairToken, {
438
+ amount: Schema.DecimalString.check(
439
+ z.describe(
440
+ 'Amount moved on this side, returned as a decimal integer string in the token’s smallest units.',
441
+ ),
442
+ z.meta({ examples: ['1000000'] }),
443
+ ),
444
+ })
445
+ .check(z.describe('One side of a swap: the token and amount that moved.'))
446
+
447
+ /** A single maker-order fill within a swap, framed from the taker's perspective. */
448
+ export const Fill = z
449
+ .object({
450
+ destinationToken: SwapSide.check(
451
+ z.describe(
452
+ 'Token and amount the taker received in this fill, with token metadata when requested via `include=tokens`.',
453
+ ),
454
+ ),
455
+ logIndex: z
456
+ .number()
457
+ .check(
458
+ z.int(),
459
+ z.nonnegative(),
460
+ z.describe('Log index of this fill within its block.'),
461
+ z.meta({ examples: [0] }),
462
+ ),
463
+ maker: Schema.Address.check(z.describe('Maker address whose resting order was filled.')),
464
+ orderId: Schema.DecimalString.check(
465
+ z.describe('On-chain maker order id, returned as a decimal string.'),
466
+ ),
467
+ partialFill: z
468
+ .boolean()
469
+ .check(
470
+ z.describe('Whether this fill used only part of the maker order.'),
471
+ z.meta({ examples: [false] }),
472
+ ),
473
+ price: z
474
+ .string()
475
+ .check(
476
+ z.regex(/^\d+(\.\d+)?$/),
477
+ z.describe(
478
+ 'Quote-per-base price at fill time, returned as a fixed-decimal string with 5 decimal places. This value is direction-independent and works well for charts.',
479
+ ),
480
+ z.meta({ examples: ['1.00000'] }),
481
+ ),
482
+ sourceToken: SwapSide.check(
483
+ z.describe(
484
+ 'Token and amount the taker sent in this fill, with token metadata when requested via `include=tokens`.',
485
+ ),
486
+ ),
487
+ })
488
+ .check(
489
+ z.describe('One maker-order fill within a swap, described from the taker’s perspective.'),
490
+ )
491
+
492
+ /**
493
+ * A single logical taker swap on the stablecoin DEX: every maker-order
494
+ * fill one taker's swap produced in one transaction, assembled into a
495
+ * source → destination row. Multi-hop swaps (routed through an
496
+ * intermediate token, e.g. pathUSD) are one row; the intermediate hops
497
+ * are visible via `route` and `fills`.
498
+ */
499
+ export const Swap = z
500
+ .object({
501
+ blockNumber: z
502
+ .number()
503
+ .check(
504
+ z.int(),
505
+ z.nonnegative(),
506
+ z.describe('Block number where the swap was included.'),
507
+ z.meta({ examples: [23456789] }),
508
+ ),
509
+ destinationToken: SwapSide.check(
510
+ z.describe(
511
+ 'Destination side of the swap: what the taker received, summed across the final hop.',
512
+ ),
513
+ ),
514
+ filledAt: z.iso
515
+ .datetime()
516
+ .check(
517
+ z.describe('Block timestamp when the swap was filled onchain.'),
518
+ z.meta({ examples: ['2024-01-01T00:00:00Z'] }),
519
+ ),
520
+ fills: z
521
+ .array(Fill)
522
+ .check(
523
+ z.describe(
524
+ 'Maker-order fills that make up this swap, ordered by execution `logIndex`. Exact-destination swaps can execute hops in reverse, so use `route` for the source-to-destination path.',
525
+ ),
526
+ ),
527
+ id: z.string().check(
528
+ z.describe(
529
+ 'Stable API id built from the lowercase transaction hash and the first fill log index.',
530
+ ),
531
+ z.meta({
532
+ examples: ['0x515801d7f9a5ac705e793e85904c9c69b3f1694b465cc8fb6ba3f0298dc82665-0'],
533
+ }),
534
+ ),
535
+ logIndex: z
536
+ .number()
537
+ .check(
538
+ z.int(),
539
+ z.nonnegative(),
540
+ z.describe('Block-wide log index for the swap, taken from its first fill.'),
541
+ z.meta({ examples: [0] }),
542
+ ),
543
+ mode: z
544
+ .nullable(z.enum(['exactSource', 'exactDestination']))
545
+ .check(
546
+ z.describe(
547
+ 'The amount type fixed by the swap call: `exactSource` for `swapExactAmountIn` or `exactDestination` for `swapExactAmountOut`. This is recovered from transaction calldata and is `null` when the swap did not come from a decodable swap call.',
548
+ ),
549
+ z.meta({ examples: ['exactSource'] }),
550
+ ),
551
+ rate: z
552
+ .string()
553
+ .check(
554
+ z.regex(/^\d+(\.\d+)?$/),
555
+ z.describe(
556
+ 'Swap-level effective rate, calculated as destination amount divided by source amount from the taker’s perspective and returned with 5 decimal places. Compare it with `1` to see distance from peg.',
557
+ ),
558
+ z.meta({ examples: ['1.00000'] }),
559
+ ),
560
+ route: z
561
+ .array(Schema.TokenAddress)
562
+ .check(
563
+ z.describe(
564
+ 'Token path for the swap, from source through any intermediate tokens to destination. `route.length - 1` is the hop count; two entries means a direct swap.',
565
+ ),
566
+ ),
567
+ sourceToken: SwapSide.check(
568
+ z.describe('Source side of the swap: what the taker sent, summed across the first hop.'),
569
+ ),
570
+ taker: Schema.Address.check(z.describe('Taker address that initiated the swap.')),
571
+ transactionHash: Schema.Hash.check(
572
+ z.describe('Transaction hash for the transaction containing the swap.'),
573
+ ),
574
+ })
575
+ .check(z.describe('One logical taker swap on Tempo’s built-in stablecoin exchange.'))
576
+
577
+ /**
578
+ * Page of swaps. A row is one logical taker swap — the fills of one
579
+ * `(transaction, taker)` group chained source → destination — so the
580
+ * unscoped and token-filtered feeds share one shape. `limit` caps the
581
+ * rows per page; a transaction batching several unrelated swaps by one
582
+ * taker may have its rows split across consecutive pages.
583
+ */
584
+ export const Response = z
585
+ .object({
586
+ data: z.array(Swap).check(z.describe('Swaps returned on this page.')),
587
+ nextCursor: Schema.NextCursor,
588
+ })
589
+ .check(z.describe('A paginated list of swaps on Tempo’s built-in stablecoin exchange.'))
590
+ }
591
+
592
+ /** Schemas for the getPairOhlc operation (candlestick aggregations). */
593
+ export namespace getPairOhlc {
594
+ /**
595
+ * Path parameter mirrors `getPair.Params`: the base address alone
596
+ * identifies the pair (its quote is intrinsic to the base token on-chain).
597
+ */
598
+ export const Params = z
599
+ .object({
600
+ base: Schema.tokenAddress(exampleBase).check(
601
+ z.describe('Base token address for the trading pair.'),
602
+ ),
603
+ })
604
+ .check(z.describe('Path parameters for fetching OHLC candles for a pair.'))
605
+
606
+ /** Bucket size for the candlestick aggregation. */
607
+ export const Interval = z
608
+ .enum(['1m', '5m', '15m', '1h', '4h', '1d'])
609
+ .check(z.describe('Time size for each OHLC candle bucket.'), z.meta({ examples: ['1h'] }))
610
+
611
+ /** Rolling lookback window for the candlestick aggregation. */
612
+ export const Window = z
613
+ .enum(['1h', '24h', '7d', '30d'])
614
+ .check(
615
+ z.describe('Rolling time window covered by the OHLC candles.'),
616
+ z.meta({ examples: ['24h'] }),
617
+ )
618
+
619
+ /**
620
+ * Optional related resource that callers opt into via `include`. Currently
621
+ * only `tokens` (embeds metadata onto `base`/`quote` instead of returning
622
+ * just the addresses).
623
+ */
624
+ export const Include = z
625
+ .enum(['tokens'])
626
+ .check(z.describe('Related resource you can opt into with `include`.'))
627
+
628
+ /**
629
+ * Parses a comma-separated `include` query value into a list of optional
630
+ * resources to embed.
631
+ */
632
+ export const includeQuery = Schema.includeQuery(
633
+ Include,
634
+ 'Comma-separated optional resources to embed, e.g. `tokens`.',
635
+ )
636
+
637
+ /** Query parameters for pair OHLC requests. */
638
+ export const Query = z
639
+ .strictObject({
640
+ chainId: Schema.ChainIdQuery,
641
+ include: includeQuery,
642
+ interval: z._default(Interval, '1h').check(z.meta({ examples: ['1h'] })),
643
+ window: z._default(Window, '24h').check(z.meta({ examples: ['24h'] })),
644
+ })
645
+ .check(
646
+ z.refine((query) => windowMs(query.window) / intervalMs(query.interval) <= maxBuckets, {
647
+ error: `Requested \`window\` / \`interval\` produces more than ${maxBuckets} buckets. Pick a longer \`interval\` or a shorter \`window\`.`,
648
+ path: ['interval'],
649
+ }),
650
+ )
651
+ .check(z.describe('Query options for fetching OHLC candles for a pair.'))
652
+
653
+ /**
654
+ * A single candlestick bucket. Open/close are picked from the earliest
655
+ * and latest fill in the bucket (ordered by `(block_num, log_idx)`); high
656
+ * and low are the extrema across all fills in the bucket. Volumes are
657
+ * the sum of per-fill base- and quote-side amounts in base units.
658
+ */
659
+ export const Bucket = z
660
+ .object({
661
+ close: z
662
+ .string()
663
+ .check(
664
+ z.regex(/^\d+(\.\d+)?$/),
665
+ z.describe(
666
+ 'Close price for the bucket: the latest fill rate, returned with 5 decimal places.',
667
+ ),
668
+ z.meta({ examples: ['1.00000'] }),
669
+ ),
670
+ fillCount: z
671
+ .number()
672
+ .check(
673
+ z.int(),
674
+ z.nonnegative(),
675
+ z.describe('Number of fills included in this bucket.'),
676
+ z.meta({ examples: [5] }),
677
+ ),
678
+ high: z
679
+ .string()
680
+ .check(
681
+ z.regex(/^\d+(\.\d+)?$/),
682
+ z.describe(
683
+ 'High price for the bucket: the highest fill rate, returned with 5 decimal places.',
684
+ ),
685
+ z.meta({ examples: ['1.01000'] }),
686
+ ),
687
+ id: z.string().check(
688
+ z.describe(
689
+ 'Stable API id built from the pair key, candle interval, and bucket start time in Unix seconds.',
690
+ ),
691
+ z.meta({
692
+ examples: [
693
+ '0x515801d7f9a5ac705e793e85904c9c69b3f1694b465cc8fb6ba3f0298dc82665-1h-1704067200',
694
+ ],
695
+ }),
696
+ ),
697
+ low: z
698
+ .string()
699
+ .check(
700
+ z.regex(/^\d+(\.\d+)?$/),
701
+ z.describe(
702
+ 'Low price for the bucket: the lowest fill rate, returned with 5 decimal places.',
703
+ ),
704
+ z.meta({ examples: ['0.99000'] }),
705
+ ),
706
+ open: z
707
+ .string()
708
+ .check(
709
+ z.regex(/^\d+(\.\d+)?$/),
710
+ z.describe(
711
+ 'Open price for the bucket: the earliest fill rate, returned with 5 decimal places.',
712
+ ),
713
+ z.meta({ examples: ['1.00000'] }),
714
+ ),
715
+ timestamp: z.iso
716
+ .datetime()
717
+ .check(
718
+ z.describe('ISO 8601 timestamp for the start of this candle bucket.'),
719
+ z.meta({ examples: ['2024-01-01T00:00:00Z'] }),
720
+ ),
721
+ volume: z
722
+ .object({
723
+ base: Schema.DecimalString.check(
724
+ z.describe(
725
+ 'Total base-side amount filled in this bucket, in the token’s smallest units.',
726
+ ),
727
+ ),
728
+ quote: Schema.DecimalString.check(
729
+ z.describe(
730
+ 'Total quote-side amount filled in this bucket, reconstructed from each fill’s tick and returned in the token’s smallest units.',
731
+ ),
732
+ ),
733
+ })
734
+ .check(z.describe('Total filled volume in this bucket, split by pair side.')),
735
+ })
736
+ .check(z.describe('One OHLC candle bucket for charting a trading pair.'))
737
+
738
+ /** Page of candlestick buckets for a single trading pair. */
739
+ export const Response = z
740
+ .object({
741
+ base: PairToken.check(z.describe('Base side of the trading pair.')),
742
+ data: z
743
+ .array(Bucket)
744
+ .check(
745
+ z.describe('OHLC candle buckets ordered oldest to newest; empty buckets are omitted.'),
746
+ ),
747
+ interval: Interval,
748
+ quote: PairToken.check(z.describe('Quote side of the trading pair.')),
749
+ truncated: z
750
+ .boolean()
751
+ .check(
752
+ z.describe(
753
+ '`true` when the fill scan hit its hard cap, meaning OHLC values were computed from only the most recent fills.',
754
+ ),
755
+ z.meta({ examples: [false] }),
756
+ ),
757
+ window: Window,
758
+ })
759
+ .check(z.describe('OHLC candle data for one trading pair over a rolling time window.'))
760
+ }
761
+
762
+ /** Schemas shared by the order detail and order fills handlers. */
763
+ export namespace order {
764
+ /** Path parameters for any single-order route (`/orders/:orderId`). */
765
+ export const Params = z
766
+ .object({
767
+ orderId: Schema.DecimalString.check(
768
+ z.describe('On-chain order id, returned as a decimal string.'),
769
+ z.meta({ examples: ['1'] }),
770
+ ),
771
+ })
772
+ .check(z.describe('Path parameters for routes that look up one order.'))
773
+
774
+ /** The pair the order is placed against. */
775
+ export const Pair = Schema.describe(
776
+ z.object({
777
+ base: PairToken.check(z.describe('Base side of the pair; the order trades this token.')),
778
+ key: Schema.Hash.check(z.describe('On-chain key for this `(base, quote)` trading pair.')),
779
+ quote: PairToken.check(z.describe('Quote side of the trading pair.')),
780
+ }),
781
+ 'The trading pair the order belongs to.',
782
+ )
783
+ }
784
+
785
+ /** Schemas for the getOrder operation. */
786
+ export namespace getOrder {
787
+ /** Path parameters for single-order requests. */
788
+ export const Params = order.Params
789
+
790
+ /**
791
+ * Optional related resource that callers opt into via `include`. Currently
792
+ * only `tokens` (embeds metadata for both pair tokens on `pair.base` and
793
+ * `pair.quote`).
794
+ */
795
+ export const Include = z
796
+ .enum(['tokens'])
797
+ .check(z.describe('Related resource you can opt into with `include`.'))
798
+
799
+ /**
800
+ * Parses a comma-separated `include` query value into a list of optional
801
+ * resources to embed. Extra lookups (e.g. pair-token metadata) only run
802
+ * when explicitly requested, keeping the base order response fast.
803
+ */
804
+ export const includeQuery = Schema.includeQuery(
805
+ Include,
806
+ 'Comma-separated optional resources to embed, e.g. `tokens`.',
807
+ )
808
+
809
+ /** Query parameters for single-order requests. */
810
+ export const Query = z
811
+ .strictObject({
812
+ chainId: Schema.ChainIdQuery,
813
+ include: includeQuery,
814
+ })
815
+ .check(z.describe('Query options for fetching one order.'))
816
+
817
+ /**
818
+ * A single DEX order. Placement parameters and current state are sourced
819
+ * directly from the on-chain `dex.getOrder` view, so `remaining` reflects
820
+ * live state (not indexer lag) and the response is a single fast RPC.
821
+ *
822
+ * Fills are paginated separately under
823
+ * `GET /exchange/orders/{orderId}/fills`; the per-order fills feed grows
824
+ * over time and would otherwise force this resource to revalidate on
825
+ * every new fill.
826
+ */
827
+ export const Response = Schema.describe(
828
+ z.object({
829
+ amount: Schema.DecimalString.check(
830
+ z.describe(
831
+ 'Initial order size, returned as a decimal integer string in base-token smallest units.',
832
+ ),
833
+ ),
834
+ flipTick: z
835
+ .number()
836
+ .check(
837
+ z.int(),
838
+ z.describe(
839
+ 'Replacement `tick` used if this order auto-flips into a counter-order after filling.',
840
+ ),
841
+ z.meta({ examples: [10] }),
842
+ ),
843
+ id: Schema.DecimalString.check(
844
+ z.describe('Stable API id for this order; it is the same value as the onchain order id.'),
845
+ ),
846
+ isBid: z
847
+ .boolean()
848
+ .check(
849
+ z.describe(
850
+ 'Order side as a boolean: `true` means the maker buys base, and `false` means the maker sells base.',
851
+ ),
852
+ z.meta({ examples: [true] }),
853
+ ),
854
+ isFlipOrder: z
855
+ .boolean()
856
+ .check(
857
+ z.describe('Whether this order automatically becomes a counter-order after it fills.'),
858
+ z.meta({ examples: [false] }),
859
+ ),
860
+ maker: Schema.Address.check(z.describe('Maker address that owns the order.')),
861
+ mode: z
862
+ .enum(['exactSource', 'exactDestination'])
863
+ .check(
864
+ z.describe(
865
+ 'Taker-perspective mode for the order. `exactSource` applies to maker bids where the taker sells base; `exactDestination` applies to maker asks where the taker buys base.',
866
+ ),
867
+ z.meta({ examples: ['exactSource'] }),
868
+ ),
869
+ orderId: Schema.DecimalString.check(
870
+ z.describe('On-chain order id, returned as a decimal string.'),
871
+ ),
872
+ pair: order.Pair,
873
+ price: z
874
+ .string()
875
+ .check(
876
+ z.regex(/^\d+(\.\d+)?$/),
877
+ z.describe(
878
+ 'Quote-per-base price implied by `tick`, returned as a fixed-decimal string with 5 decimal places.',
879
+ ),
880
+ z.meta({ examples: ['1.00000'] }),
881
+ ),
882
+ rate: z
883
+ .string()
884
+ .check(
885
+ z.regex(/^\d+(\.\d+)?$/),
886
+ z.describe(
887
+ 'Destination-to-source price ratio, returned as a fixed-decimal string with 5 decimal places.',
888
+ ),
889
+ z.meta({ examples: ['1.00000'] }),
890
+ ),
891
+ remaining: Schema.DecimalString.check(
892
+ z.describe(
893
+ 'Unfilled base-side amount still resting on the orderbook, returned as a decimal integer string.',
894
+ ),
895
+ ),
896
+ tick: z
897
+ .number()
898
+ .check(
899
+ z.int(),
900
+ z.describe(
901
+ 'On-chain signed tick, scaled by `1/priceScale`; use `price` for the decoded ratio.',
902
+ ),
903
+ z.meta({ examples: [0] }),
904
+ ),
905
+ }),
906
+ 'A single DEX order with live onchain state.',
907
+ )
908
+ }
909
+
910
+ /** Schemas for the getOrders operation (the resting-order feed, optionally pair-scoped). */
911
+ export namespace getOrders {
912
+ /**
913
+ * Optional resources for the order feed: the per-row `tokens` embed plus
914
+ * the response-wide capped `totalCount` (the number of matching resting
915
+ * orders, a lower bound when `truncated`).
916
+ */
917
+ export const Include = z
918
+ .enum(['tokens', 'totalCount'])
919
+ .check(
920
+ z.describe(
921
+ 'Extra resources you can request with `include`, such as token metadata or a total count.',
922
+ ),
923
+ )
924
+
925
+ /**
926
+ * Parses a comma-separated `include` query value into a list of optional
927
+ * resources to embed.
928
+ */
929
+ export const includeQuery = Schema.includeQuery(
930
+ Include,
931
+ 'Comma-separated optional resources to embed, e.g. `tokens,totalCount`.',
932
+ )
933
+
934
+ /** Query parameters for resting-order feed requests. */
935
+ export const Query = z
936
+ .strictObject({
937
+ base: z
938
+ // The shared `TokenAddress` example is pathUSD — the universal
939
+ // *quote* — which is never a valid base; use a real base instead.
940
+ .optional(Schema.tokenAddress(exampleBase))
941
+ .check(
942
+ z.describe(
943
+ 'Only include orders for the pair with this base token. The quote token is determined onchain from the base token, so `base` alone selects the pair; returns 404 if no pair exists.',
944
+ ),
945
+ ),
946
+ chainId: Schema.ChainIdQuery,
947
+ cursor: Schema.Cursor,
948
+ include: includeQuery,
949
+ limit: Schema.Limit,
950
+ maker: z
951
+ .optional(Schema.Address)
952
+ .check(z.describe('Only include orders placed by this maker address.')),
953
+ order: Schema.Order,
954
+ page: Schema.Page,
955
+ side: z
956
+ .optional(z.enum(['bid', 'ask']))
957
+ .check(
958
+ z.describe(
959
+ 'Limit results to one side of the orderbook: `bid` orders pay quote for base, while `ask` orders sell base for quote.',
960
+ ),
961
+ z.meta({ examples: ['bid'] }),
962
+ ),
963
+ sort: z
964
+ ._default(z.enum(['tick', 'time']), 'tick')
965
+ .check(
966
+ z.describe(
967
+ 'Sort key: `tick` orders by price in standard book order, while `time` orders by block number and log index.',
968
+ ),
969
+ z.meta({ examples: ['tick'] }),
970
+ ),
971
+ })
972
+ .check(z.describe('Filters, pagination, and sorting options for listing resting orders.'))
973
+
974
+ /**
975
+ * A single resting maker order, sans its pair (rows are shaped here and
976
+ * then annotated with their pair). `amount` is the originally-placed base
977
+ * amount and `remaining` is what's still on the book (`amount` minus the
978
+ * sum of `OrderFilled.amountFilled` over all fills for this order).
979
+ * Orders for which `remaining <= 0` or that have an `OrderCancelled`
980
+ * event are excluded from the response.
981
+ */
982
+ export const RestingOrder = z
983
+ .object({
984
+ amount: Schema.DecimalString.check(
985
+ z.describe(
986
+ 'Original placed amount, returned as a decimal integer string in base-token smallest units.',
987
+ ),
988
+ ),
989
+ blockNumber: z
990
+ .number()
991
+ .check(
992
+ z.int(),
993
+ z.nonnegative(),
994
+ z.describe('Block number where the order was placed.'),
995
+ z.meta({ examples: [23456789] }),
996
+ ),
997
+ id: Schema.DecimalString.check(
998
+ z.describe('Stable API id for this order; it is the same value as the onchain order id.'),
999
+ ),
1000
+ logIndex: z
1001
+ .number()
1002
+ .check(
1003
+ z.int(),
1004
+ z.nonnegative(),
1005
+ z.describe('Log index of the order placement within its block.'),
1006
+ z.meta({ examples: [0] }),
1007
+ ),
1008
+ maker: Schema.Address.check(z.describe('Maker address that owns the order.')),
1009
+ orderId: Schema.DecimalString.check(
1010
+ z.describe('On-chain order id, returned as a decimal string.'),
1011
+ ),
1012
+ placedAt: z.iso
1013
+ .datetime()
1014
+ .check(
1015
+ z.describe('Block timestamp when the order was placed.'),
1016
+ z.meta({ examples: ['2024-01-01T00:00:00Z'] }),
1017
+ ),
1018
+ price: z
1019
+ .string()
1020
+ .check(
1021
+ z.regex(/^\d+(\.\d+)?$/),
1022
+ z.describe(
1023
+ 'Quote-per-base limit price implied by `tick`, returned with 5 decimal places. This value is direction-independent; use `rate` for the taker-perspective ratio.',
1024
+ ),
1025
+ z.meta({ examples: ['1.00000'] }),
1026
+ ),
1027
+ rate: z
1028
+ .string()
1029
+ .check(
1030
+ z.regex(/^\d+(\.\d+)?$/),
1031
+ z.describe(
1032
+ 'Order limit price as a destination/source ratio from the taker’s perspective, returned with 5 decimal places. Use `price` for orderbook displays.',
1033
+ ),
1034
+ z.meta({ examples: ['1.00000'] }),
1035
+ ),
1036
+ remaining: Schema.DecimalString.check(
1037
+ z.describe(
1038
+ 'Unfilled remainder of `amount`, returned as a decimal integer string in base-token smallest units. This is always positive for rows in this response.',
1039
+ ),
1040
+ ),
1041
+ side: z
1042
+ .enum(['bid', 'ask'])
1043
+ .check(
1044
+ z.describe(
1045
+ 'Orderbook side: `bid` pays quote for base, and `ask` sells base for quote.',
1046
+ ),
1047
+ z.meta({ examples: ['bid'] }),
1048
+ ),
1049
+ tick: z
1050
+ .number()
1051
+ .check(
1052
+ z.int(),
1053
+ z.describe('Order tick as a signed offset from peg; one tick equals `1/priceScale`.'),
1054
+ z.meta({ examples: [0] }),
1055
+ ),
1056
+ transactionHash: Schema.Hash.check(
1057
+ z.describe('Transaction hash for the transaction that placed the order.'),
1058
+ ),
1059
+ })
1060
+ .check(z.describe('One resting maker order on Tempo’s built-in stablecoin exchange.'))
1061
+
1062
+ /** A resting maker order annotated with the trading pair it belongs to. */
1063
+ export const Order = z
1064
+ .extend(RestingOrder, {
1065
+ pair: order.Pair,
1066
+ })
1067
+ .check(z.describe('A resting maker order plus its trading pair.'))
1068
+
1069
+ /** Page of resting maker orders, each annotated with its trading pair. */
1070
+ export const Response = z
1071
+ .object({
1072
+ data: z.array(Order).check(z.describe('Resting orders returned on this page.')),
1073
+ meta: z
1074
+ .optional(Schema.CountMeta)
1075
+ .check(
1076
+ z.describe('Response-level resources requested with `include`, such as `totalCount`.'),
1077
+ ),
1078
+ nextCursor: Schema.NextCursor,
1079
+ truncated: z
1080
+ .boolean()
1081
+ .check(
1082
+ z.describe(
1083
+ '`true` when the `OrderPlaced` scan hit its hard cap, meaning the resting set was computed from only the most recent placements.',
1084
+ ),
1085
+ z.meta({ examples: [false] }),
1086
+ ),
1087
+ })
1088
+ .check(
1089
+ z.describe(
1090
+ 'A paginated list of resting maker orders on Tempo’s built-in stablecoin exchange.',
1091
+ ),
1092
+ )
1093
+ }
1094
+
1095
+ /** Schemas for the getOrderFills operation. */
1096
+ export namespace getOrderFills {
1097
+ /** Path parameters: inherits the canonical `/orders/:orderId` shape. */
1098
+ export const Params = order.Params
1099
+
1100
+ /** Query parameters for paginated order-fill requests. */
1101
+ export const Query = z
1102
+ .strictObject({
1103
+ chainId: Schema.ChainIdQuery,
1104
+ cursor: Schema.Cursor,
1105
+ include: Schema.totalCountInclude,
1106
+ limit: Schema.Limit,
1107
+ order: Schema.Order,
1108
+ page: Schema.Page,
1109
+ })
1110
+ .check(
1111
+ ...Schema.pageChecks(),
1112
+ z.describe('Pagination and sorting options for listing fills for one order.'),
1113
+ )
1114
+
1115
+ /**
1116
+ * A single fill against the order. The order's pair, maker, side, and
1117
+ * rate are constant across all fills, so they live on the `Order`
1118
+ * resource rather than being repeated per row.
1119
+ */
1120
+ export const Fill = z
1121
+ .object({
1122
+ amountFilled: Schema.DecimalString.check(
1123
+ z.describe(
1124
+ 'Base-side amount filled by this event, returned as a decimal integer string in base-token smallest units.',
1125
+ ),
1126
+ ),
1127
+ blockNumber: z
1128
+ .number()
1129
+ .check(
1130
+ z.int(),
1131
+ z.nonnegative(),
1132
+ z.describe('Block number where the fill was included.'),
1133
+ z.meta({ examples: [23456789] }),
1134
+ ),
1135
+ filledAt: z.iso
1136
+ .datetime()
1137
+ .check(
1138
+ z.describe('Block timestamp when the fill landed onchain.'),
1139
+ z.meta({ examples: ['2024-01-01T00:00:00Z'] }),
1140
+ ),
1141
+ id: z.string().check(
1142
+ z.describe('Stable API id built from the lowercase transaction hash and log index.'),
1143
+ z.meta({
1144
+ examples: ['0x515801d7f9a5ac705e793e85904c9c69b3f1694b465cc8fb6ba3f0298dc82665-0'],
1145
+ }),
1146
+ ),
1147
+ logIndex: z
1148
+ .number()
1149
+ .check(
1150
+ z.int(),
1151
+ z.nonnegative(),
1152
+ z.describe('Log index of this fill within its block.'),
1153
+ z.meta({ examples: [0] }),
1154
+ ),
1155
+ orderId: Schema.DecimalString.check(
1156
+ z.describe('On-chain order id, returned as a decimal string.'),
1157
+ ),
1158
+ partialFill: z
1159
+ .boolean()
1160
+ .check(
1161
+ z.describe('Whether this fill used only part of the maker order.'),
1162
+ z.meta({ examples: [false] }),
1163
+ ),
1164
+ taker: Schema.Address.check(z.describe('Taker address that submitted the incoming order.')),
1165
+ transactionHash: Schema.Hash.check(
1166
+ z.describe('Transaction hash for the transaction containing the fill.'),
1167
+ ),
1168
+ })
1169
+ .check(z.describe('One `OrderFilled` event against this order.'))
1170
+
1171
+ /** Paginated page of fills for one order. */
1172
+ export const Response = z
1173
+ .object({
1174
+ data: z.array(Fill).check(z.describe('Fills returned on this page.')),
1175
+ meta: z
1176
+ .optional(Schema.CountMeta)
1177
+ .check(z.describe('Response-level resources requested with `include`.')),
1178
+ nextCursor: Schema.NextCursor,
1179
+ })
1180
+ .check(z.describe('A paginated list of fill events for one DEX order.'))
1181
+ }
1182
+
1183
+ /** Schemas for the getPairDepth operation (orderbook depth aggregation). */
1184
+ export namespace getPairDepth {
1185
+ /**
1186
+ * Path parameter mirrors `getPair.Params`: the base address alone
1187
+ * identifies the pair (its quote is intrinsic to the base token on-chain).
1188
+ */
1189
+ export const Params = z
1190
+ .object({
1191
+ base: Schema.tokenAddress(exampleBase).check(
1192
+ z.describe('Base token address for the trading pair.'),
1193
+ ),
1194
+ })
1195
+ .check(z.describe('Path parameters for fetching orderbook depth for a pair.'))
1196
+
1197
+ /**
1198
+ * Optional related resource that callers opt into via `include`. Currently
1199
+ * only `tokens` (embeds metadata onto `base`/`quote` instead of returning
1200
+ * just the addresses).
1201
+ */
1202
+ export const Include = z
1203
+ .enum(['tokens'])
1204
+ .check(z.describe('Related resource you can opt into with `include`.'))
1205
+
1206
+ /**
1207
+ * Parses a comma-separated `include` query value into a list of optional
1208
+ * resources to embed.
1209
+ */
1210
+ export const includeQuery = Schema.includeQuery(
1211
+ Include,
1212
+ 'Comma-separated optional resources to embed, e.g. `tokens`.',
1213
+ )
1214
+
1215
+ /** Query parameters for pair depth requests. */
1216
+ export const Query = z
1217
+ .strictObject({
1218
+ chainId: Schema.ChainIdQuery,
1219
+ include: includeQuery,
1220
+ levels: z
1221
+ ._default(
1222
+ z.coerce.number().check(z.int(), z.gte(1), z.lte(maxDepthLevels)),
1223
+ defaultDepthLevels,
1224
+ )
1225
+ .check(
1226
+ z.describe(
1227
+ `Maximum number of non-empty price levels to return per side (1-${maxDepthLevels}).`,
1228
+ ),
1229
+ z.meta({ examples: [50] }),
1230
+ ),
1231
+ })
1232
+ .check(z.describe('Query options for fetching orderbook depth for a pair.'))
1233
+
1234
+ /**
1235
+ * A single price-level entry in the orderbook depth. `size` is the raw
1236
+ * `totalLiquidity` reported by the DEX precompile for the level (resting
1237
+ * base-token amount); `cumulativeSize` is the running sum across the same
1238
+ * side, starting from the best (peg-adjacent) populated tick and walking
1239
+ * outward.
1240
+ */
1241
+ export const Level = z
1242
+ .object({
1243
+ cumulativeSize: Schema.DecimalString.check(
1244
+ z.describe(
1245
+ 'Running sum of `size` from the best populated tick outward to this level, in base-token smallest units.',
1246
+ ),
1247
+ ),
1248
+ id: z.string().check(
1249
+ z.describe('Stable API id built from the pair key, orderbook side, and tick.'),
1250
+ z.meta({
1251
+ examples: ['0x515801d7f9a5ac705e793e85904c9c69b3f1694b465cc8fb6ba3f0298dc82665-bid-0'],
1252
+ }),
1253
+ ),
1254
+ price: z
1255
+ .string()
1256
+ .check(
1257
+ z.regex(/^\d+(\.\d+)?$/),
1258
+ z.describe('Tick converted to a price ratio with 5 decimal places.'),
1259
+ z.meta({ examples: ['1.00000'] }),
1260
+ ),
1261
+ size: Schema.DecimalString.check(
1262
+ z.describe('Resting liquidity at this tick level, in base-token smallest units.'),
1263
+ ),
1264
+ tick: z
1265
+ .number()
1266
+ .check(
1267
+ z.int(),
1268
+ z.describe('Signed price tick (`int16`), always a multiple of the DEX tick spacing.'),
1269
+ z.meta({ examples: [0] }),
1270
+ ),
1271
+ })
1272
+ .check(z.describe('One price level in the orderbook depth response.'))
1273
+
1274
+ /** Orderbook depth (cumulative size per tick) for a single trading pair. */
1275
+ export const Response = z
1276
+ .object({
1277
+ asks: z
1278
+ .array(Level)
1279
+ .check(
1280
+ z.describe(
1281
+ 'Ask-side levels, where makers sell base and takers buy it, ordered from best lowest ask outward.',
1282
+ ),
1283
+ ),
1284
+ base: PairToken.check(z.describe('Base side of the trading pair.')),
1285
+ bids: z
1286
+ .array(Level)
1287
+ .check(
1288
+ z.describe(
1289
+ 'Bid-side levels, where makers buy base and takers sell it, ordered from best highest bid outward.',
1290
+ ),
1291
+ ),
1292
+ quote: PairToken.check(z.describe('Quote side of the trading pair.')),
1293
+ })
1294
+ .check(z.describe('Orderbook depth for one trading pair, with cumulative size at each tick.'))
1295
+ }
1296
+ }
1297
+
1298
+ /**
1299
+ * Creates exchange handlers. The first endpoint is `GET /pairs`, which lists
1300
+ * trading pairs on the stablecoin DEX precompile by replaying its `PairCreated`
1301
+ * events through the indexer.
1302
+ *
1303
+ * Two sort orders are supported:
1304
+ * - `sort=created` (default): order by pair creation `(block_num, log_idx)`.
1305
+ * - `sort=liquidity`: rank by the DEX precompile's escrow balance of the pair's
1306
+ * base token, a per-pair liquidity proxy. Each base address participates in
1307
+ * exactly one pair (the on-chain DEX keys orderbooks by base, so the same
1308
+ * base never appears in two pairs), which lets a single
1309
+ * `token_balances_snapshot` row attribute cleanly to one pair. Pairs whose
1310
+ * DEX-escrow balance is zero are excluded from the liquidity ranking.
1311
+ */
1312
+ export function exchanges() {
1313
+ return new Hono<App.Environment>()
1314
+ .get(
1315
+ '/swaps',
1316
+ Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }),
1317
+ OpenApi.validate('query', schema.getSwaps.Query, {
1318
+ code: 'query_invalid',
1319
+ message: 'Invalid query parameters',
1320
+ }),
1321
+ OpenApi.describeRoute({
1322
+ description:
1323
+ 'List recent swaps on Tempo’s built-in stablecoin exchange. A swap trades one token for another and may contain multiple maker-order fills.',
1324
+ operationId: 'getSwaps',
1325
+ responses: OpenApi.responses({
1326
+ errors: {
1327
+ 502: 'The indexer could not serve the exchange data right now.',
1328
+ },
1329
+ success: {
1330
+ description: 'A page of swaps from Tempo’s built-in stablecoin exchange.',
1331
+ schema: schema.getSwaps.Response,
1332
+ },
1333
+ }),
1334
+ summary: 'List swaps',
1335
+ tags: ['Exchange'],
1336
+ }),
1337
+ Cache.response<App.Environment>({
1338
+ // Exchanges are a newest-first feed; refresh quickly. The `v2` cache
1339
+ // segment isolates the swap-level row shape from previously cached
1340
+ // per-fill pages (cursor pages cache as `immutable`).
1341
+ cacheControl: Cache.policies.feed,
1342
+ name: 'tempo-api:exchange:v2:swaps',
1343
+ key: (c) => Cache.urlKey(c, schema.getSwaps.Query),
1344
+ }),
1345
+ async (c) => {
1346
+ if (Auth.narrowAccess) return Auth.paidAccessError(c)
1347
+ if (OpenApi.narrowValidation)
1348
+ return OpenApi.validationError(c, {
1349
+ code: 'query_invalid',
1350
+ message: 'Invalid query parameters',
1351
+ })
1352
+
1353
+ const options = c.req.valid('query')
1354
+ const chainId = options.chainId ?? c.get('chainId')
1355
+
1356
+ // Cursor pages anchor below the head and are effectively immutable, so
1357
+ // upgrade their cache policy; the head page keeps the route's `feed`
1358
+ // default.
1359
+ if (options.cursor !== undefined) Cache.setPolicy(c, Cache.policies.immutable)
1360
+
1361
+ try {
1362
+ // The pair index (base address → pair) resolves each fill's book
1363
+ // orientation (the order events only carry the base-side `token`;
1364
+ // the quote is intrinsic to it).
1365
+ const pairIndex = await getPairIndex(c, { chainId })
1366
+
1367
+ const page = await getSwaps(c, {
1368
+ chainId,
1369
+ cursor: options.cursor,
1370
+ fromBlock: options['blockNumber.from'],
1371
+ fromTimestamp: options['timestamp.from'],
1372
+ limit: options.limit,
1373
+ maker: options.maker,
1374
+ order: options.order,
1375
+ pairIndex,
1376
+ participant: options.participant,
1377
+ taker: options.taker,
1378
+ toBlock: options['blockNumber.to'],
1379
+ toTimestamp: options['timestamp.to'],
1380
+ transactionHash: options.transactionHash,
1381
+ })
1382
+
1383
+ // Token metadata is opt-in. Collect every token address across the
1384
+ // page (`route` spans each swap's source, intermediates, and
1385
+ // destination — a superset of every fill side) and resolve each
1386
+ // once, then spread it onto the swap-level sides and every fill
1387
+ // side. Resolution is best-effort: a token whose metadata is
1388
+ // unavailable keeps just `address`/`amount`.
1389
+ let data = page.data
1390
+ if (options.include.includes('tokens') && data.length > 0) {
1391
+ const tokensByAddress = await resolveTokensByAddress(c, {
1392
+ addresses: data.flatMap((swap) => swap.route),
1393
+ chainId,
1394
+ })
1395
+ type Side = (typeof data)[number]['sourceToken']
1396
+ const spread = <side extends Side>(side: side): side => {
1397
+ const token = tokensByAddress.get(side.address)
1398
+ return token ? { ...side, ...token } : side
1399
+ }
1400
+ data = data.map((swap) => ({
1401
+ ...swap,
1402
+ destinationToken: spread(swap.destinationToken),
1403
+ fills: swap.fills.map((fill) => ({
1404
+ ...fill,
1405
+ destinationToken: spread(fill.destinationToken),
1406
+ sourceToken: spread(fill.sourceToken),
1407
+ })),
1408
+ sourceToken: spread(swap.sourceToken),
1409
+ }))
1410
+ }
1411
+
1412
+ return c.json(
1413
+ Response.validated(schema.getSwaps.Response, {
1414
+ data,
1415
+ nextCursor: page.nextCursor,
1416
+ }),
1417
+ 200,
1418
+ )
1419
+ } catch (cause) {
1420
+ return Response.upstream(c, cause)
1421
+ }
1422
+ },
1423
+ )
1424
+ .get(
1425
+ '/pairs',
1426
+ Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }),
1427
+ OpenApi.validate('query', schema.getPairs.Query, {
1428
+ code: 'query_invalid',
1429
+ message: 'Invalid query parameters',
1430
+ }),
1431
+ OpenApi.describeRoute({
1432
+ description: 'List trading pairs available on Tempo’s built-in stablecoin exchange.',
1433
+ operationId: 'getPairs',
1434
+ responses: OpenApi.responses({
1435
+ errors: { 502: 'The indexer could not serve the exchange data right now.' },
1436
+ success: {
1437
+ description: 'A page of exchange trading pairs.',
1438
+ schema: schema.getPairs.Response,
1439
+ },
1440
+ }),
1441
+ summary: 'List pairs',
1442
+ tags: ['Exchange'],
1443
+ }),
1444
+ Cache.response<App.Environment>({
1445
+ // Pair creation is rare and append-only; `metadata` matches token-listing
1446
+ // volatility and amortizes the indexer round-trip well.
1447
+ cacheControl: Cache.policies.metadata,
1448
+ name: 'tempo-api:exchange:v1',
1449
+ key: (c) => Cache.urlKey(c, schema.getPairs.Query),
1450
+ }),
1451
+ async (c) => {
1452
+ if (Auth.narrowAccess) return Auth.paidAccessError(c)
1453
+ if (OpenApi.narrowValidation)
1454
+ return OpenApi.validationError(c, {
1455
+ code: 'query_invalid',
1456
+ message: 'Invalid query parameters',
1457
+ })
1458
+
1459
+ const options = c.req.valid('query')
1460
+ const chainId = options.chainId ?? c.get('chainId')
1461
+
1462
+ // Cursor pages are anchored below the head and effectively immutable, so
1463
+ // cache them aggressively; the head page keeps the route's `metadata`
1464
+ // default.
1465
+ if (options.cursor !== undefined) Cache.setPolicy(c, Cache.policies.immutable)
1466
+
1467
+ try {
1468
+ // `totalCount` is opt-in: the pair total is independent of `sort`
1469
+ // (both orderings page the same set), so count `dex_pairs` directly.
1470
+ // Best-effort: a count failure omits `meta` rather than failing.
1471
+ const countPromise = options.include.includes('totalCount')
1472
+ ? countPairs(c, { chainId }).catch(() => undefined)
1473
+ : undefined
1474
+
1475
+ const page = await getPairs(c, {
1476
+ chainId,
1477
+ cursor: options.cursor,
1478
+ limit: options.limit,
1479
+ order: options.order,
1480
+ page: options.page,
1481
+ sort: options.sort,
1482
+ })
1483
+
1484
+ // Token metadata is opt-in. When requested, resolve each unique pair
1485
+ // token's metadata concurrently (sharing one verified-token fetch) and
1486
+ // spread it into both the `base` and `quote` row fields. Resolution is
1487
+ // best-effort: a token whose metadata is unavailable keeps the row's
1488
+ // `base`/`quote` as just `address` rather than failing the whole page.
1489
+ let data: z.output<typeof schema.getPairs.Pair>[] = page.data.map((pair) => ({
1490
+ base: { address: pair.base },
1491
+ blockNumber: pair.blockNumber,
1492
+ id: pair.key,
1493
+ key: pair.key,
1494
+ ...(pair.liquidity !== undefined && { liquidity: pair.liquidity }),
1495
+ quote: { address: pair.quote },
1496
+ timestamp: pair.timestamp,
1497
+ transactionHash: pair.transactionHash,
1498
+ }))
1499
+ if (options.include.includes('tokens') && data.length > 0) {
1500
+ const tokensByAddress = await resolveTokensByAddress(c, {
1501
+ addresses: data.flatMap((pair) => [pair.base.address, pair.quote.address]),
1502
+ chainId,
1503
+ })
1504
+ data = data.map((pair) => {
1505
+ const base = tokensByAddress.get(pair.base.address)
1506
+ const quote = tokensByAddress.get(pair.quote.address)
1507
+ return {
1508
+ ...pair,
1509
+ base: base ? { ...pair.base, ...base } : pair.base,
1510
+ quote: quote ? { ...pair.quote, ...quote } : pair.quote,
1511
+ }
1512
+ })
1513
+ }
1514
+
1515
+ const meta = countPromise ? await countPromise : undefined
1516
+ return c.json(
1517
+ Response.validated(schema.getPairs.Response, {
1518
+ data,
1519
+ ...(meta ? { meta } : {}),
1520
+ nextCursor: page.nextCursor,
1521
+ }),
1522
+ 200,
1523
+ )
1524
+ } catch (cause) {
1525
+ return Response.upstream(c, cause)
1526
+ }
1527
+ },
1528
+ )
1529
+ .get(
1530
+ // Single trading pair detail. The base address alone identifies the
1531
+ // pair (its quote is intrinsic to the base token on-chain).
1532
+ '/pairs/:base',
1533
+ Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }),
1534
+ OpenApi.validate('param', schema.getPair.Params, {
1535
+ code: 'pair_invalid',
1536
+ message: 'Invalid base token',
1537
+ }),
1538
+ OpenApi.validate('query', schema.getPair.Query, {
1539
+ code: 'query_invalid',
1540
+ message: 'Invalid query parameters',
1541
+ }),
1542
+ OpenApi.describeRoute({
1543
+ description:
1544
+ 'Get one trading pair by its base token address. On Tempo, the quote token for a pair is determined from the base token onchain.',
1545
+ operationId: 'getPair',
1546
+ responses: OpenApi.responses({
1547
+ errors: {
1548
+ 404: {
1549
+ description: 'No exchange pair was found for that base token.',
1550
+ codes: ['pair_not_found'],
1551
+ },
1552
+ 502: 'The indexer could not serve the exchange data right now.',
1553
+ },
1554
+ success: {
1555
+ description: 'Details for one exchange trading pair.',
1556
+ schema: schema.getPair.Response,
1557
+ },
1558
+ }),
1559
+ summary: 'Get pair',
1560
+ tags: ['Exchange'],
1561
+ }),
1562
+ Cache.response<App.Environment>({
1563
+ // Pair creation context is immutable once created; `metadata` matches
1564
+ // the pairs listing.
1565
+ cacheControl: Cache.policies.metadata,
1566
+ name: 'tempo-api:exchange:v1:pair',
1567
+ key: (c) => Cache.urlKey(c, schema.getPair.Query),
1568
+ }),
1569
+ async (c) => {
1570
+ if (Auth.narrowAccess) return Auth.paidAccessError(c)
1571
+ if (OpenApi.narrowValidation)
1572
+ return OpenApi.validationError(c, {
1573
+ code: 'pair_invalid',
1574
+ message: 'Invalid request parameters',
1575
+ })
1576
+ if (OpenApi.narrowValidation)
1577
+ return OpenApi.validationError(c, {
1578
+ code: 'query_invalid',
1579
+ message: 'Invalid request parameters',
1580
+ })
1581
+
1582
+ const { base } = c.req.valid('param')
1583
+ const options = c.req.valid('query')
1584
+ const chainId = options.chainId ?? c.get('chainId')
1585
+
1586
+ try {
1587
+ const pairs = await getPairIndex(c, { chainId })
1588
+ const target = pairs.get(base)
1589
+ if (!target) return pairNotFoundError(c, { base, pairIndex: pairs })
1590
+
1591
+ // Token metadata is opt-in: resolve both pair tokens once and spread
1592
+ // onto the `base`/`quote` PairToken slots.
1593
+ let baseToken: z.output<typeof schema.PairToken> = { address: target.base }
1594
+ let quoteToken: z.output<typeof schema.PairToken> = { address: target.quote }
1595
+ if (options.include.includes('tokens')) {
1596
+ const tokensByAddress = await resolveTokensByAddress(c, {
1597
+ addresses: [target.base, target.quote],
1598
+ chainId,
1599
+ })
1600
+ const baseMeta = tokensByAddress.get(target.base)
1601
+ const quoteMeta = tokensByAddress.get(target.quote)
1602
+ if (baseMeta) baseToken = { ...baseToken, ...baseMeta }
1603
+ if (quoteMeta) quoteToken = { ...quoteToken, ...quoteMeta }
1604
+ }
1605
+
1606
+ return c.json(
1607
+ Response.validated(schema.getPair.Response, {
1608
+ base: baseToken,
1609
+ blockNumber: target.blockNumber,
1610
+ id: target.key,
1611
+ key: target.key,
1612
+ quote: quoteToken,
1613
+ timestamp: target.timestamp,
1614
+ transactionHash: target.transactionHash,
1615
+ }),
1616
+ 200,
1617
+ )
1618
+ } catch (cause) {
1619
+ return Response.upstream(c, cause)
1620
+ }
1621
+ },
1622
+ )
1623
+ .get(
1624
+ // Resting-order feed, optionally scoped to one pair via `base`.
1625
+ '/orders',
1626
+ Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }),
1627
+ OpenApi.validate('query', schema.getOrders.Query, {
1628
+ code: 'query_invalid',
1629
+ message: 'Invalid query parameters',
1630
+ }),
1631
+ OpenApi.describeRoute({
1632
+ description:
1633
+ 'List resting maker orders on Tempo’s built-in stablecoin exchange. These are open orders waiting in the orderbook.',
1634
+ operationId: 'getOrders',
1635
+ responses: OpenApi.responses({
1636
+ errors: {
1637
+ 404: {
1638
+ description: 'No exchange pair was found for the requested `base` filter.',
1639
+ codes: ['pair_not_found'],
1640
+ },
1641
+ 502: 'The indexer could not serve the exchange data right now.',
1642
+ },
1643
+ success: {
1644
+ description: 'A page of resting maker orders.',
1645
+ schema: schema.getOrders.Response,
1646
+ },
1647
+ }),
1648
+ summary: 'List orders',
1649
+ tags: ['Exchange'],
1650
+ }),
1651
+ Cache.response<App.Environment>({
1652
+ // Order book changes second-to-second; refresh quickly. The handler
1653
+ // memoizes the underlying OrderPlaced scan internally for ~15s.
1654
+ cacheControl: Cache.policies.state,
1655
+ name: 'tempo-api:exchange:v1:all-orders',
1656
+ key: (c) => Cache.urlKey(c, schema.getOrders.Query),
1657
+ }),
1658
+ async (c) => {
1659
+ if (Auth.narrowAccess) return Auth.paidAccessError(c)
1660
+ if (OpenApi.narrowValidation)
1661
+ return OpenApi.validationError(c, {
1662
+ code: 'query_invalid',
1663
+ message: 'Invalid query parameters',
1664
+ })
1665
+
1666
+ const options = c.req.valid('query')
1667
+ const chainId = options.chainId ?? c.get('chainId')
1668
+
1669
+ try {
1670
+ // The pair index (base address → pair) resolves the `base` selector
1671
+ // and each order's pair; the OrderPlaced scan only carries the
1672
+ // base-side `token`.
1673
+ const pairIndex = await getPairIndex(c, { chainId })
1674
+
1675
+ // `base` selects the single pair it identifies — a pair's quote is
1676
+ // intrinsic to its base token on-chain, so the base address alone
1677
+ // is a complete selector.
1678
+ let pair: getPairs.Row | undefined
1679
+ if (options.base !== undefined) {
1680
+ const target = pairIndex.get(options.base)
1681
+ if (!target) return pairNotFoundError(c, { base: options.base, pairIndex })
1682
+ pair = target
1683
+ }
1684
+
1685
+ const page = await getOrders(c, {
1686
+ chainId,
1687
+ cursor: options.cursor,
1688
+ limit: options.limit,
1689
+ maker: options.maker,
1690
+ order: options.order,
1691
+ page: options.page,
1692
+ pair,
1693
+ pairIndex,
1694
+ side: options.side,
1695
+ sort: options.sort,
1696
+ })
1697
+
1698
+ // Token metadata is opt-in. Collect every pair-token address across
1699
+ // the page, resolve each once, then spread it onto whichever pair
1700
+ // side carries that address. Best-effort: a token whose metadata is
1701
+ // unavailable keeps just `address`.
1702
+ let data = page.data
1703
+ if (options.include.includes('tokens') && data.length > 0) {
1704
+ const tokensByAddress = await resolveTokensByAddress(c, {
1705
+ addresses: data.flatMap((o) => [o.pair.base.address, o.pair.quote.address]),
1706
+ chainId,
1707
+ })
1708
+ data = data.map((o) => {
1709
+ const base = tokensByAddress.get(o.pair.base.address)
1710
+ const quote = tokensByAddress.get(o.pair.quote.address)
1711
+ return {
1712
+ ...o,
1713
+ pair: {
1714
+ ...o.pair,
1715
+ base: base ? { ...o.pair.base, ...base } : o.pair.base,
1716
+ quote: quote ? { ...o.pair.quote, ...quote } : o.pair.quote,
1717
+ },
1718
+ }
1719
+ })
1720
+ }
1721
+
1722
+ // `totalCount` is opt-in and computed in-memory from the resting-order
1723
+ // snapshot (no extra query): the matching-order total, capped when the
1724
+ // placement scan was truncated (a lower bound).
1725
+ const meta = options.include.includes('totalCount')
1726
+ ? {
1727
+ // `totalCountCapped` when the placement scan was truncated OR the matching
1728
+ // set itself reaches the cap — either way the total is a lower bound.
1729
+ totalCountCapped: page.truncated || page.total >= Schema.countCap,
1730
+ totalCount: Math.min(page.total, Schema.countCap),
1731
+ }
1732
+ : undefined
1733
+ return c.json(
1734
+ Response.validated(schema.getOrders.Response, {
1735
+ data,
1736
+ ...(meta ? { meta } : {}),
1737
+ nextCursor: page.nextCursor,
1738
+ truncated: page.truncated,
1739
+ }),
1740
+ 200,
1741
+ )
1742
+ } catch (cause) {
1743
+ return Response.upstream(c, cause)
1744
+ }
1745
+ },
1746
+ )
1747
+ .get(
1748
+ // Single-order detail. The path-param schema rejects non-decimal
1749
+ // `orderId` values, surfacing the canonical `order_invalid` error.
1750
+ '/orders/:orderId',
1751
+ Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }),
1752
+ OpenApi.validate('param', schema.getOrder.Params, {
1753
+ code: 'order_invalid',
1754
+ message: 'Invalid order id',
1755
+ }),
1756
+ OpenApi.validate('query', schema.getOrder.Query, {
1757
+ code: 'query_invalid',
1758
+ message: 'Invalid query parameters',
1759
+ }),
1760
+ OpenApi.describeRoute({
1761
+ description:
1762
+ 'Get one order by id, using live onchain state for fields such as the remaining amount.',
1763
+ operationId: 'getOrder',
1764
+ responses: OpenApi.responses({
1765
+ errors: {
1766
+ 404: {
1767
+ description: 'No exchange order was found for that id.',
1768
+ codes: ['order_not_found'],
1769
+ },
1770
+ 502: 'The Tempo RPC node could not serve the exchange data right now.',
1771
+ },
1772
+ success: {
1773
+ description: 'Details for one exchange order.',
1774
+ schema: schema.getOrder.Response,
1775
+ },
1776
+ }),
1777
+ summary: 'Get order',
1778
+ tags: ['Exchange'],
1779
+ }),
1780
+ Cache.response<App.Environment>({
1781
+ // `dex.getOrder` returns live state (`remaining` decreases as fills
1782
+ // land), so cache as `state`. The handler upgrades to `immutable` when
1783
+ // `remaining === '0'`, since a fully-filled order is terminal — every
1784
+ // field this resource exposes is then immutable.
1785
+ cacheControl: Cache.policies.state,
1786
+ name: 'tempo-api:exchange:v1:order',
1787
+ key: (c) => Cache.urlKey(c, schema.getOrder.Query),
1788
+ }),
1789
+ async (c) => {
1790
+ if (Auth.narrowAccess) return Auth.paidAccessError(c)
1791
+ if (OpenApi.narrowValidation)
1792
+ return OpenApi.validationError(c, {
1793
+ code: 'order_invalid',
1794
+ message: 'Invalid request parameters',
1795
+ })
1796
+ if (OpenApi.narrowValidation)
1797
+ return OpenApi.validationError(c, {
1798
+ code: 'query_invalid',
1799
+ message: 'Invalid request parameters',
1800
+ })
1801
+
1802
+ const { orderId } = c.req.valid('param')
1803
+ const query = c.req.valid('query')
1804
+ const chainId = query.chainId ?? c.get('chainId')
1805
+
1806
+ try {
1807
+ const order = await getOrder(c, { chainId, orderId })
1808
+ if (!order)
1809
+ return Response.error(c, {
1810
+ code: 'order_not_found',
1811
+ message: 'Order not found',
1812
+ status: 404,
1813
+ })
1814
+
1815
+ // Fully-filled orders are terminal — `remaining`/`amount`/everything
1816
+ // else is fixed forever, so they can be cached aggressively.
1817
+ if (order.remaining === '0') Cache.setPolicy(c, Cache.policies.immutable)
1818
+
1819
+ // Pair-token metadata is opt-in. The pair only has two tokens so we
1820
+ // resolve both concurrently behind a single verified-tokens fetch and
1821
+ // one batched `IN (...)` `tokens_created_at` query, matching the
1822
+ // pair-swap include path. Resolution is best-effort: a token whose
1823
+ // metadata is unavailable keeps just `address`.
1824
+ let pair = order.pair
1825
+ if (query.include.includes('tokens')) {
1826
+ const uniqueTokens = [pair.base.address, pair.quote.address]
1827
+ const snapshot = await VerifiedTokens.snapshot(c, chainId)
1828
+ const resolved = await Timing.time(c, 'tokens', () =>
1829
+ Promise.all(
1830
+ uniqueTokens.map((token) =>
1831
+ Tokens.resolveToken(c, {
1832
+ address: token,
1833
+ chainId,
1834
+ snapshot,
1835
+ })
1836
+ .then((metadata) => [token, metadata] as const)
1837
+ .catch(() => [token, undefined] as const),
1838
+ ),
1839
+ ),
1840
+ )
1841
+ const tokensByAddress = new Map(resolved)
1842
+ const base = tokensByAddress.get(pair.base.address)
1843
+ const quote = tokensByAddress.get(pair.quote.address)
1844
+ pair = {
1845
+ ...pair,
1846
+ base: base ? { ...pair.base, ...base } : pair.base,
1847
+ quote: quote ? { ...pair.quote, ...quote } : pair.quote,
1848
+ }
1849
+ }
1850
+
1851
+ return c.json(Response.validated(schema.getOrder.Response, { ...order, pair }), 200)
1852
+ } catch (cause) {
1853
+ return Response.upstream(c, cause)
1854
+ }
1855
+ },
1856
+ )
1857
+ .get(
1858
+ // Paginated fill history for a single order.
1859
+ '/orders/:orderId/fills',
1860
+ Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }),
1861
+ OpenApi.validate('param', schema.getOrderFills.Params, {
1862
+ code: 'order_invalid',
1863
+ message: 'Invalid order id',
1864
+ }),
1865
+ OpenApi.validate('query', schema.getOrderFills.Query, {
1866
+ code: 'query_invalid',
1867
+ message: 'Invalid query parameters',
1868
+ }),
1869
+ OpenApi.describeRoute({
1870
+ description:
1871
+ 'List fills for one order. A fill records an execution against the maker order.',
1872
+ operationId: 'getOrderFills',
1873
+ responses: OpenApi.responses({
1874
+ errors: { 502: 'The indexer could not serve the exchange data right now.' },
1875
+ success: {
1876
+ description: 'A page of fills for one order.',
1877
+ schema: schema.getOrderFills.Response,
1878
+ },
1879
+ }),
1880
+ summary: 'List order fills',
1881
+ tags: ['Exchange'],
1882
+ }),
1883
+ Cache.response<App.Environment>({
1884
+ // Newest-first fill feed — refresh quickly so newly-landed fills appear
1885
+ // on the head page. Cursor pages anchor below the head and are
1886
+ // effectively immutable; upgraded below.
1887
+ cacheControl: Cache.policies.feed,
1888
+ name: 'tempo-api:exchange:v1:order:fills',
1889
+ key: (c) => Cache.urlKey(c, schema.getOrderFills.Query),
1890
+ }),
1891
+ async (c) => {
1892
+ if (Auth.narrowAccess) return Auth.paidAccessError(c)
1893
+ if (OpenApi.narrowValidation)
1894
+ return OpenApi.validationError(c, {
1895
+ code: 'order_invalid',
1896
+ message: 'Invalid request parameters',
1897
+ })
1898
+ if (OpenApi.narrowValidation)
1899
+ return OpenApi.validationError(c, {
1900
+ code: 'query_invalid',
1901
+ message: 'Invalid request parameters',
1902
+ })
1903
+
1904
+ const { orderId } = c.req.valid('param')
1905
+ const options = c.req.valid('query')
1906
+ const chainId = options.chainId ?? c.get('chainId')
1907
+
1908
+ // Cursor pages anchor below the head on a stable `(block_num, log_idx)`
1909
+ // tuple, so they are effectively immutable across requests; only the
1910
+ // head page tracks the live tail.
1911
+ if (options.cursor !== undefined) Cache.setPolicy(c, Cache.policies.immutable)
1912
+
1913
+ try {
1914
+ // `totalCount` (the order's fill total) is opt-in and shares the
1915
+ // page's `orderId` filter; run it concurrently with the page.
1916
+ // Best-effort: a count failure omits `meta` rather than failing the page.
1917
+ const countPromise = options.include.includes('totalCount')
1918
+ ? countOrderFills(c, { chainId, orderId }).catch(() => undefined)
1919
+ : undefined
1920
+
1921
+ const page = await getOrderFills(c, {
1922
+ chainId,
1923
+ cursor: options.cursor,
1924
+ limit: options.limit,
1925
+ order: options.order,
1926
+ page: options.page,
1927
+ orderId,
1928
+ })
1929
+
1930
+ const meta = countPromise ? await countPromise : undefined
1931
+
1932
+ return c.json(
1933
+ Response.validated(schema.getOrderFills.Response, {
1934
+ data: page.data,
1935
+ ...(meta ? { meta } : {}),
1936
+ nextCursor: page.nextCursor,
1937
+ }),
1938
+ 200,
1939
+ )
1940
+ } catch (cause) {
1941
+ return Response.upstream(c, cause)
1942
+ }
1943
+ },
1944
+ )
1945
+ .get(
1946
+ // OHLC (candlestick) aggregations for a single pair, addressed by its
1947
+ // base token.
1948
+ '/pairs/:base/ohlc',
1949
+ Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }),
1950
+ OpenApi.validate('param', schema.getPairOhlc.Params, {
1951
+ code: 'pair_invalid',
1952
+ message: 'Invalid pair tokens',
1953
+ }),
1954
+ OpenApi.validate('query', schema.getPairOhlc.Query, {
1955
+ code: 'query_invalid',
1956
+ message: 'Invalid query parameters',
1957
+ }),
1958
+ OpenApi.describeRoute({
1959
+ description:
1960
+ 'Get OHLC price candles for one trading pair. OHLC means open, high, low, and close over each time bucket for charting.',
1961
+ operationId: 'getPairOhlc',
1962
+ responses: OpenApi.responses({
1963
+ errors: {
1964
+ 404: {
1965
+ description: 'No exchange pair was found for that base token.',
1966
+ codes: ['pair_not_found'],
1967
+ },
1968
+ 502: 'The indexer could not serve the exchange data right now.',
1969
+ },
1970
+ success: {
1971
+ description: 'OHLC candle data for the pair.',
1972
+ schema: schema.getPairOhlc.Response,
1973
+ },
1974
+ }),
1975
+ summary: 'Get pair OHLC',
1976
+ tags: ['Exchange'],
1977
+ }),
1978
+ Cache.response<App.Environment>({
1979
+ // Short-interval candles change second-to-second so the latest bucket
1980
+ // is volatile; the per-route handler upgrades the policy for longer
1981
+ // intervals where staleness is cheaper.
1982
+ cacheControl: Cache.policies.feed,
1983
+ name: 'tempo-api:exchange:v1:ohlc',
1984
+ key: (c) => Cache.urlKey(c, schema.getPairOhlc.Query),
1985
+ }),
1986
+ async (c) => {
1987
+ if (Auth.narrowAccess) return Auth.paidAccessError(c)
1988
+ if (OpenApi.narrowValidation)
1989
+ return OpenApi.validationError(c, {
1990
+ code: 'pair_invalid',
1991
+ message: 'Invalid request parameters',
1992
+ })
1993
+ if (OpenApi.narrowValidation)
1994
+ return OpenApi.validationError(c, {
1995
+ code: 'query_invalid',
1996
+ message: 'Invalid request parameters',
1997
+ })
1998
+
1999
+ const { base: baseAddress } = c.req.valid('param')
2000
+ const options = c.req.valid('query')
2001
+ const chainId = options.chainId ?? c.get('chainId')
2002
+
2003
+ // Hour+ candles change slowly; upgrade them to the `state` tier so we
2004
+ // amortize the per-fill scan more aggressively. The 1m/5m/15m tiers
2005
+ // keep the route's `feed` default.
2006
+ if (options.interval === '1h' || options.interval === '4h' || options.interval === '1d')
2007
+ Cache.setPolicy(c, Cache.policies.state)
2008
+
2009
+ try {
2010
+ const pairs = await getPairIndex(c, { chainId })
2011
+ const target = pairs.get(baseAddress)
2012
+ if (!target) return pairNotFoundError(c, { base: baseAddress, pairIndex: pairs })
2013
+
2014
+ const result = await getPairOhlc(c, {
2015
+ chainId,
2016
+ interval: options.interval,
2017
+ pair: target,
2018
+ window: options.window,
2019
+ })
2020
+
2021
+ // Token metadata is opt-in. Resolve once per pair and spread onto the
2022
+ // top-level `base`/`quote` PairToken slots (the inner bucket rows
2023
+ // carry no token references, so no per-row enrichment is needed).
2024
+ let base: z.output<typeof schema.PairToken> = { address: target.base }
2025
+ let quote: z.output<typeof schema.PairToken> = { address: target.quote }
2026
+ if (options.include.includes('tokens')) {
2027
+ const tokensByAddress = await resolveTokensByAddress(c, {
2028
+ addresses: [target.base, target.quote],
2029
+ chainId,
2030
+ })
2031
+ const baseMeta = tokensByAddress.get(target.base)
2032
+ const quoteMeta = tokensByAddress.get(target.quote)
2033
+ if (baseMeta) base = { ...base, ...baseMeta }
2034
+ if (quoteMeta) quote = { ...quote, ...quoteMeta }
2035
+ }
2036
+
2037
+ return c.json(
2038
+ Response.validated(schema.getPairOhlc.Response, {
2039
+ base,
2040
+ data: result.data,
2041
+ interval: options.interval,
2042
+ quote,
2043
+ truncated: result.truncated,
2044
+ window: options.window,
2045
+ }),
2046
+ 200,
2047
+ )
2048
+ } catch (cause) {
2049
+ return Response.upstream(c, cause)
2050
+ }
2051
+ },
2052
+ )
2053
+ .get(
2054
+ // Orderbook depth (cumulative size per tick) for a single pair,
2055
+ // addressed by its base token.
2056
+ '/pairs/:base/depth',
2057
+ Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }),
2058
+ OpenApi.validate('param', schema.getPairDepth.Params, {
2059
+ code: 'pair_invalid',
2060
+ message: 'Invalid pair tokens',
2061
+ }),
2062
+ OpenApi.validate('query', schema.getPairDepth.Query, {
2063
+ code: 'query_invalid',
2064
+ message: 'Invalid query parameters',
2065
+ }),
2066
+ OpenApi.describeRoute({
2067
+ description:
2068
+ 'Get orderbook depth for one trading pair. Depth shows resting liquidity at price ticks on both sides of the book.',
2069
+ operationId: 'getPairDepth',
2070
+ responses: OpenApi.responses({
2071
+ errors: {
2072
+ 404: {
2073
+ description: 'No exchange pair was found for that base token.',
2074
+ codes: ['pair_not_found'],
2075
+ },
2076
+ 502: 'The Tempo RPC node could not serve the exchange data right now.',
2077
+ },
2078
+ success: {
2079
+ description: 'Orderbook depth data for the pair.',
2080
+ schema: schema.getPairDepth.Response,
2081
+ },
2082
+ }),
2083
+ summary: 'Get pair depth',
2084
+ tags: ['Exchange'],
2085
+ }),
2086
+ Cache.response<App.Environment>({
2087
+ // Depth shifts on every order placement, fill, or cancellation; `feed`
2088
+ // matches the order-of-magnitude staleness traders tolerate on a
2089
+ // depth chart while still amortizing concurrent dashboard loads.
2090
+ cacheControl: Cache.policies.feed,
2091
+ name: 'tempo-api:exchange:v1:depth',
2092
+ key: (c) => Cache.urlKey(c, schema.getPairDepth.Query),
2093
+ }),
2094
+ async (c) => {
2095
+ if (Auth.narrowAccess) return Auth.paidAccessError(c)
2096
+ if (OpenApi.narrowValidation)
2097
+ return OpenApi.validationError(c, {
2098
+ code: 'pair_invalid',
2099
+ message: 'Invalid request parameters',
2100
+ })
2101
+ if (OpenApi.narrowValidation)
2102
+ return OpenApi.validationError(c, {
2103
+ code: 'query_invalid',
2104
+ message: 'Invalid request parameters',
2105
+ })
2106
+
2107
+ const { base: baseAddress } = c.req.valid('param')
2108
+ const options = c.req.valid('query')
2109
+ const chainId = options.chainId ?? c.get('chainId')
2110
+
2111
+ try {
2112
+ const pairs = await getPairIndex(c, { chainId })
2113
+ const target = pairs.get(baseAddress)
2114
+ if (!target) return pairNotFoundError(c, { base: baseAddress, pairIndex: pairs })
2115
+
2116
+ const { asks, bids } = await getPairDepth(c, {
2117
+ chainId,
2118
+ levels: options.levels,
2119
+ pair: target,
2120
+ })
2121
+
2122
+ // Token metadata is opt-in. Resolve once per pair and spread onto the
2123
+ // top-level `base`/`quote` PairToken slots. The Level rows carry no
2124
+ // token references so no per-row enrichment is needed.
2125
+ let base: z.output<typeof schema.PairToken> = { address: target.base }
2126
+ let quote: z.output<typeof schema.PairToken> = { address: target.quote }
2127
+ if (options.include.includes('tokens')) {
2128
+ const tokensByAddress = await resolveTokensByAddress(c, {
2129
+ addresses: [target.base, target.quote],
2130
+ chainId,
2131
+ })
2132
+ const baseMeta = tokensByAddress.get(target.base)
2133
+ const quoteMeta = tokensByAddress.get(target.quote)
2134
+ if (baseMeta) base = { ...base, ...baseMeta }
2135
+ if (quoteMeta) quote = { ...quote, ...quoteMeta }
2136
+ }
2137
+
2138
+ return c.json(
2139
+ Response.validated(schema.getPairDepth.Response, { asks, base, bids, quote }),
2140
+ 200,
2141
+ )
2142
+ } catch (cause) {
2143
+ return Response.upstream(c, cause)
2144
+ }
2145
+ },
2146
+ )
2147
+ }
2148
+
2149
+ // Hard cap on the curated pair index. Pair creation is rare (one event per
2150
+ // pair lifetime), so a few thousand bounds today's set comfortably; raise this
2151
+ // if the index ever exhausts.
2152
+ const pairIndexLimit = 5000
2153
+
2154
+ /**
2155
+ * Builds the 404 for a failed pair lookup. The most common mistake is passing
2156
+ * the *quote* side of the pair (usually pathUSD) as `base` — every pair quotes
2157
+ * in pathUSD directly or transitively, so its address is never a base. Detect
2158
+ * that case and point the caller at the right selector instead of returning a
2159
+ * bare "not found".
2160
+ */
2161
+ function pairNotFoundError(
2162
+ c: Context<App.Environment>,
2163
+ options: { base: string; pairIndex: Map<string, getPairs.Row> },
2164
+ ) {
2165
+ const { base, pairIndex } = options
2166
+ const message = [...pairIndex.values()].some((pair) => pair.quote === base)
2167
+ ? 'Pair not found: the address is a quote token; address the pair by its base token (see `GET /exchange/pairs`)'
2168
+ : 'Pair not found'
2169
+ return Response.error(c, { code: 'pair_not_found', message, status: 404 })
2170
+ }
2171
+
2172
+ /**
2173
+ * Batch-resolves token metadata for a set of addresses, sharing one
2174
+ * verified-token snapshot and one created-at lookup across the whole set.
2175
+ * Best-effort: addresses whose metadata cannot be resolved are absent from
2176
+ * the returned map, so callers keep their bare `{ address }` shapes. Shared
2177
+ * by every `include=tokens` embed in this module.
2178
+ */
2179
+ async function resolveTokensByAddress(
2180
+ c: Context<App.Environment>,
2181
+ options: {
2182
+ addresses: readonly z.output<typeof Schema.Address>[]
2183
+ chainId: z.output<typeof Schema.ChainId>
2184
+ },
2185
+ ) {
2186
+ const { addresses, chainId } = options
2187
+ type Token = Awaited<ReturnType<typeof Tokens.resolveToken>>
2188
+ const tokensByAddress = new Map<string, Token>()
2189
+ const uniqueTokens = [...new Set(addresses)]
2190
+ if (uniqueTokens.length === 0) return tokensByAddress
2191
+
2192
+ const snapshot = await VerifiedTokens.snapshot(c, chainId)
2193
+ const resolved = await Timing.time(c, 'tokens', () =>
2194
+ Promise.all(
2195
+ uniqueTokens.map((token) =>
2196
+ Tokens.resolveToken(c, { address: token, chainId, snapshot })
2197
+ .then((metadata) => [token, metadata] as const)
2198
+ .catch(() => [token, undefined] as const),
2199
+ ),
2200
+ ),
2201
+ )
2202
+ for (const [token, metadata] of resolved) if (metadata) tokensByAddress.set(token, metadata)
2203
+ return tokensByAddress
2204
+ }
2205
+
2206
+ async function getPairs(c: Context<App.Environment>, options: getPairs.Options) {
2207
+ if (options.sort === 'liquidity') return getPairsByLiquidity(c, options)
2208
+ return getPairsByCreation(c, options)
2209
+ }
2210
+
2211
+ /**
2212
+ * Exact total count of trading pairs on the stablecoin DEX. Reads the
2213
+ * `dex_pairs` materialized table (one row per `PairCreated`) on ClickHouse,
2214
+ * pruning on the DEX `address` — the same engine/key the page uses. Because the
2215
+ * `address` predicate aligns with the table's sort key, ClickHouse counts via
2216
+ * the sparse primary index (no row scan), so the count is exact and cheap with
2217
+ * no cap (`totalCountCapped` is always `false`). The total is independent of the
2218
+ * page's `sort` (both orderings page the same pair set).
2219
+ */
2220
+ function countPairs(
2221
+ c: Context<App.Environment>,
2222
+ options: { chainId: z.output<typeof Schema.ChainId> },
2223
+ ): Promise<z.output<typeof Schema.CountMeta>> {
2224
+ const { chainId } = options
2225
+ const store = c.get('store')
2226
+ const tidx = c.get('getTidx')(chainId)
2227
+ return Timing.time(c, 'pairs_count', () =>
2228
+ Store.memoize(
2229
+ async () => {
2230
+ const result = await tidx.fetch({
2231
+ chainId,
2232
+ engine: 'clickhouse',
2233
+ query:
2234
+ `SELECT count(*) AS total FROM dex_pairs WHERE address = '${stablecoinDex}'` as string,
2235
+ })
2236
+ const totalCount = Value.toNumber(result.rows[0]?.['total']) ?? 0
2237
+ return { totalCountCapped: false, totalCount }
2238
+ },
2239
+ { key: `exchange:v1:${chainId}:pairs-count`, store, ttl: Ttl.seconds(60) },
2240
+ ),
2241
+ )
2242
+ }
2243
+
2244
+ async function getPairsByCreation(c: Context<App.Environment>, options: getPairs.Options) {
2245
+ const { chainId, limit, order } = options
2246
+ const direction = order === 'asc' ? 'ASC' : 'DESC'
2247
+ const store = c.get('store')
2248
+ const tidx = c.get('getTidx')(chainId)
2249
+ // Keyset pagination on `(block_num, log_idx)`: a pair is created exactly
2250
+ // once, so this tuple is a stable, unique sort key. A malformed cursor falls
2251
+ // back to the head page.
2252
+ const cursor = options.cursor ? Cursor.decode(options.cursor, ['int', 'int']) : undefined
2253
+ // Bounded positional lane (exclusive with `cursor` at the schema): page 1 is
2254
+ // the head page, so it shares the head's SQL and cache entries; deeper pages
2255
+ // translate to a SQL OFFSET of `(page - 1) * limit` rows.
2256
+ const offset =
2257
+ options.page !== undefined && options.page > 1 ? (options.page - 1) * limit : undefined
2258
+
2259
+ return Timing.time(c, 'pairs', () =>
2260
+ Store.memoize(
2261
+ async () => {
2262
+ // Pre-decoded pair rows from the stablecoin DEX precompile. Read the
2263
+ // `dex_pairs` materialized table (one row per `PairCreated`, sorted on
2264
+ // `(block_num, log_idx)`) instead of decoding the `PairCreated` event
2265
+ // signature over `logs` per request, so this is a sort-key read that
2266
+ // matches the keyset directly. Filtering by `address` keeps unrelated
2267
+ // `PairCreated`-named events (if any) out of the page. Fetch one extra
2268
+ // row to detect `hasMore` without a separate count query. The inline
2269
+ // query is cast to `string` so TIDX treats it as a dynamic ClickHouse
2270
+ // query that needs no event signature for the `dex_pairs` table.
2271
+ const filters: string[] = [`address = '${stablecoinDex}'`]
2272
+ if (cursor !== undefined)
2273
+ filters.push(
2274
+ Cursor.keyset([
2275
+ { literal: Cursor.literal(cursor[0]!, 'int'), name: 'block_num', order },
2276
+ { literal: Cursor.literal(cursor[1]!, 'int'), name: 'log_idx', order },
2277
+ ]),
2278
+ )
2279
+ const where = `WHERE ${filters.join(' AND ')}`
2280
+
2281
+ const result = await tidx.fetch({
2282
+ chainId,
2283
+ engine: 'clickhouse',
2284
+ query: `
2285
+ SELECT key, base, quote, tx_hash, block_num, log_idx, block_timestamp
2286
+ FROM dex_pairs
2287
+ ${where}
2288
+ ORDER BY block_num ${direction}, log_idx ${direction}
2289
+ LIMIT ${limit + 1}${offset !== undefined ? ` OFFSET ${offset}` : ''}
2290
+ ` as string,
2291
+ })
2292
+
2293
+ // The next page anchors below the last fetched row's `(block, log_idx)`.
2294
+ const page = Cursor.paginate({
2295
+ rows: result.rows,
2296
+ limit,
2297
+ key: (row) => {
2298
+ const block = Value.toNumber(row['block_num'])
2299
+ const index = Value.toNumber(row['log_idx'])
2300
+ return block !== undefined && index !== undefined ? [block, index] : undefined
2301
+ },
2302
+ })
2303
+
2304
+ const data: getPairs.Row[] = []
2305
+ for (const row of page.rows) {
2306
+ const parsed = parsePairRow(row)
2307
+ if (parsed) data.push(parsed)
2308
+ }
2309
+
2310
+ return { data, nextCursor: page.nextCursor }
2311
+ },
2312
+ {
2313
+ key: `exchange:v1:${chainId}:pairs:created:${order}:${cursor ? `cursor:${cursor[0]}:${cursor[1]}` : offset !== undefined ? `page:${options.page}` : 'head'}:${limit}`,
2314
+ store,
2315
+ ttl: Ttl.seconds(60),
2316
+ },
2317
+ ),
2318
+ )
2319
+ }
2320
+
2321
+ async function getPairsByLiquidity(c: Context<App.Environment>, options: getPairs.Options) {
2322
+ const { chainId, limit, order } = options
2323
+ const store = c.get('store')
2324
+ const tidx = c.get('getTidx')(chainId)
2325
+ // Keyset pagination on `(balance, token)`: balance ranks the page (mutable,
2326
+ // so this is best-effort across balance changes — the inherent limit of
2327
+ // ranking feeds), and `token` is a stable tiebreaker. A malformed cursor
2328
+ // falls back to the head page.
2329
+ const cursor = options.cursor ? Cursor.decode(options.cursor, ['uint', 'address']) : undefined
2330
+ // Bounded positional lane (exclusive with `cursor` at the schema): page 1 is
2331
+ // the head page, so it shares the head's SQL and cache entries; deeper pages
2332
+ // translate to a SQL OFFSET of `(page - 1) * limit` rows.
2333
+ const offset =
2334
+ options.page !== undefined && options.page > 1 ? (options.page - 1) * limit : undefined
2335
+ const balanceOrder = order === 'asc' ? 'ASC' : 'DESC'
2336
+
2337
+ return Timing.time(c, 'pairs', async () =>
2338
+ Store.memoize(
2339
+ async () => {
2340
+ // One ranked read from `dex_pair_liquidity`, which joins each pair's
2341
+ // base to its DEX-escrow balance in ClickHouse — replacing the previous
2342
+ // 3× over-fetch of escrow balances + in-memory intersection with the
2343
+ // pair index. The cursor keyset keeps the previous `(balance, token)`
2344
+ // value semantics under the view's `(liquidity, base)` column names, so
2345
+ // pre-existing cursors stay valid. Cast the inline query to `string`
2346
+ // (dynamic ClickHouse view, no event signature).
2347
+ const keyset =
2348
+ cursor !== undefined
2349
+ ? ` WHERE ${Cursor.keyset([
2350
+ { literal: Cursor.literal(cursor[0]!, 'uint'), name: 'liquidity', order },
2351
+ { literal: Cursor.literal(cursor[1]!, 'address'), name: 'base', order: 'asc' },
2352
+ ])}`
2353
+ : ''
2354
+ const result = await tidx.fetch({
2355
+ chainId,
2356
+ engine: 'clickhouse',
2357
+ // `liquidity` is UInt256; SELECT it as a string so values > 2^53
2358
+ // survive JSON (the cursor encodes the exact value). Alias avoids
2359
+ // shadowing the source column, which TIDX rejects (422).
2360
+ query: `
2361
+ SELECT key, base, quote, block_num, block_timestamp, tx_hash,
2362
+ toString(liquidity) AS liquidity_str
2363
+ FROM dex_pair_liquidity${keyset}
2364
+ ORDER BY liquidity ${balanceOrder}, base ASC
2365
+ LIMIT ${limit + 1}${offset !== undefined ? ` OFFSET ${offset}` : ''}
2366
+ ` as string,
2367
+ })
2368
+
2369
+ const page = Cursor.paginate({
2370
+ rows: result.rows,
2371
+ limit,
2372
+ key: (row) => {
2373
+ const liquidity = Value.toIntegerString(row['liquidity_str'])
2374
+ const base = Schema.Address.safeParse(row['base'])
2375
+ return liquidity !== undefined && base.success ? [liquidity, base.data] : undefined
2376
+ },
2377
+ })
2378
+
2379
+ const data: getPairs.Row[] = []
2380
+ for (const row of page.rows) {
2381
+ const pair = parsePairRow(row)
2382
+ const liquidity = Value.toIntegerString(row['liquidity_str'])
2383
+ if (pair && liquidity !== undefined) data.push({ ...pair, liquidity })
2384
+ }
2385
+
2386
+ return { data, nextCursor: page.nextCursor }
2387
+ },
2388
+ {
2389
+ key: `exchange:v1:${chainId}:pairs:liquidity:v2:${order}:${cursor ? `cursor:${cursor[0]}:${cursor[1]}` : offset !== undefined ? `page:${options.page}` : 'head'}:${limit}`,
2390
+ store,
2391
+ ttl: Ttl.seconds(30),
2392
+ },
2393
+ ),
2394
+ )
2395
+ }
2396
+
2397
+ /**
2398
+ * Loads the curated pair index: a map from each pair's base address to its
2399
+ * pair info (key, quote, creation context). The index is bounded by
2400
+ * `pairIndexLimit` and memoized for several minutes since pair creation is
2401
+ * rare and append-only. Reused across liquidity-ranking pages.
2402
+ */
2403
+ async function getPairIndex(c: Context<App.Environment>, options: getPairIndex.Options) {
2404
+ const { chainId } = options
2405
+ const store = c.get('store')
2406
+ const tidx = c.get('getTidx')(chainId)
2407
+
2408
+ return Timing.time(c, 'pair_index', () =>
2409
+ Store.memoize(
2410
+ async () => {
2411
+ // Read the pre-decoded `dex_pairs` materialized table instead of decoding
2412
+ // the `PairCreated` event signature over `logs` per request. The inline
2413
+ // query is cast to `string` so TIDX treats it as a dynamic ClickHouse
2414
+ // query that needs no event signature for the `dex_pairs` table.
2415
+ const result = await tidx.fetch({
2416
+ chainId,
2417
+ engine: 'clickhouse',
2418
+ query: `
2419
+ SELECT key, base, quote, tx_hash, block_num, log_idx, block_timestamp
2420
+ FROM dex_pairs
2421
+ WHERE address = '${stablecoinDex}'
2422
+ ORDER BY block_num DESC, log_idx DESC
2423
+ LIMIT ${pairIndexLimit}
2424
+ ` as string,
2425
+ })
2426
+ // Serialize as `[base, pair]` entries so the cache layer round-trips
2427
+ // them through JSON cleanly (a `Map` would not survive serialization-
2428
+ // backed stores).
2429
+ const entries: [string, getPairs.Row][] = []
2430
+ for (const row of result.rows) {
2431
+ const parsed = parsePairRow(row)
2432
+ if (parsed) entries.push([parsed.base, parsed])
2433
+ }
2434
+ return entries
2435
+ },
2436
+ {
2437
+ key: `exchange:v1:${chainId}:pair-index:v1`,
2438
+ store,
2439
+ ttl: Ttl.minutes(5),
2440
+ },
2441
+ ),
2442
+ ).then((entries) => new Map(entries))
2443
+ }
2444
+
2445
+ declare namespace getPairIndex {
2446
+ type Options = {
2447
+ chainId: z.output<typeof Schema.ChainId>
2448
+ }
2449
+ }
2450
+
2451
+ /**
2452
+ * Validates and shapes one `PairCreated` row into the internal pair shape.
2453
+ * Returns `undefined` for malformed rows so the caller can drop them without
2454
+ * failing the whole page.
2455
+ */
2456
+ function parsePairRow(row: Record<string, unknown>): getPairs.Row | undefined {
2457
+ const key = Schema.Hash.safeParse(row['key'])
2458
+ const base = Schema.TokenAddress.safeParse(row['base'])
2459
+ const quote = Schema.TokenAddress.safeParse(row['quote'])
2460
+ const transactionHash = Schema.Hash.safeParse(row['tx_hash'])
2461
+ const blockNumber = Value.toNumber(row['block_num'])
2462
+ const timestamp = Value.toIsoDateTime(row['block_timestamp'])
2463
+ if (
2464
+ !key.success ||
2465
+ !base.success ||
2466
+ !quote.success ||
2467
+ !transactionHash.success ||
2468
+ blockNumber === undefined ||
2469
+ timestamp === undefined
2470
+ )
2471
+ return undefined
2472
+ return {
2473
+ base: base.data,
2474
+ blockNumber,
2475
+ key: key.data,
2476
+ quote: quote.data,
2477
+ timestamp,
2478
+ transactionHash: transactionHash.data,
2479
+ }
2480
+ }
2481
+
2482
+ declare namespace getPairs {
2483
+ /** Options for the trading pair page query. */
2484
+ type Options = {
2485
+ chainId: z.output<typeof Schema.ChainId>
2486
+ /** Opaque keyset cursor anchoring the page; omit for the head page. */
2487
+ cursor?: string | undefined
2488
+ limit: number
2489
+ /** 1-indexed page number (positional pagination; exclusive with `cursor`). */
2490
+ page?: number | undefined
2491
+ order: 'asc' | 'desc'
2492
+ sort: 'created' | 'liquidity'
2493
+ }
2494
+
2495
+ /** A decoded `PairCreated` row before per-surface shaping. */
2496
+ type Row = {
2497
+ base: z.output<typeof Schema.TokenAddress>
2498
+ blockNumber: number
2499
+ key: z.output<typeof Schema.Hash>
2500
+ /**
2501
+ * DEX-escrow balance of the base token as a decimal integer string.
2502
+ * Present only when the page was ranked by liquidity.
2503
+ */
2504
+ liquidity?: string | undefined
2505
+ quote: z.output<typeof Schema.TokenAddress>
2506
+ timestamp: string
2507
+ transactionHash: z.output<typeof Schema.Hash>
2508
+ }
2509
+ }
2510
+
2511
+ /**
2512
+ * Resolves a page of logical taker swaps.
2513
+ *
2514
+ * A swap row aggregates the `OrderFilled` events of one
2515
+ * `(block, transaction, taker)` group into one or more leg chains (§ multi-hop
2516
+ * swaps route through intermediate books; batched transactions can carry
2517
+ * several swaps). Each candidate batch is a grouped ClickHouse query over
2518
+ * `dex_fills`; each fill's `(token, isBid, tick)` is then resolved
2519
+ * point-in-time from the raw `OrderPlaced`/`OrderFlipped` event stream
2520
+ * ({@link resolveOrderStates}), since the fill event itself carries none of
2521
+ * it. The swap-level `mode` (transaction calldata, {@link resolveSwapCalls})
2522
+ * is deferred to the final page so the candidate scan never pays it for rows
2523
+ * that don't make the page.
2524
+ *
2525
+ * Every filter is pushed into the ClickHouse query — row predicates (`taker`,
2526
+ * `transactionHash`, block/timestamp bounds) in `WHERE`, and the
2527
+ * complete-fill-set filters (`maker`, `participant`) as HAVING aggregates — so
2528
+ * `LIMIT` counts matching groups exactly and no row is dropped after
2529
+ * pagination. A batched transaction can still expand one group into several
2530
+ * swap rows, so the page is capped at row granularity: the cursor carries
2531
+ * `(block, group first_idx, row first logIndex)`, the anchor group is
2532
+ * re-fetched on the next page, and its already-returned rows are dropped
2533
+ * row-wise — `data` never exceeds `limit`, and a group split across a page
2534
+ * boundary resumes mid-group.
2535
+ *
2536
+ * Because a group expands into one *or more* rows (and the rare un-orientable
2537
+ * group into zero), the scan accumulates rows across SQL batches (advancing
2538
+ * an internal cursor by whole groups) until it has `limit + 1` (more remain)
2539
+ * or the feed is exhausted; `nextCursor` then anchors at the last *included*
2540
+ * row. This is a row-count top-up over the group→row fan-out, not a filter —
2541
+ * no candidate is discarded after the query runs.
2542
+ */
2543
+ async function getSwaps(c: Context<App.Environment>, options: getSwaps.Options) {
2544
+ const {
2545
+ chainId,
2546
+ cursor: cursorToken,
2547
+ fromBlock,
2548
+ fromTimestamp,
2549
+ limit,
2550
+ maker,
2551
+ order,
2552
+ pairIndex,
2553
+ participant,
2554
+ taker,
2555
+ toBlock,
2556
+ toTimestamp,
2557
+ transactionHash,
2558
+ } = options
2559
+ const tidx = c.get('getTidx')(chainId)
2560
+ const direction = order === 'asc' ? 'ASC' : 'DESC'
2561
+ // Keyset cursor on `(block_num, group first_idx, row first log_idx)`: the
2562
+ // group key scopes the SQL re-fetch, the row key resumes inside a group
2563
+ // that was split across a page boundary. A malformed cursor falls back to
2564
+ // the head page.
2565
+ const cursor = cursorToken ? Cursor.decode(cursorToken, ['int', 'int', 'int']) : undefined
2566
+
2567
+ // Inclusive `dex_fills` block range bounding one grouped batch (see
2568
+ // `swapWindowBlocks`).
2569
+ type BlockWindow = { hi: number; lo: number }
2570
+
2571
+ // Row-level predicates (constant within a group) prune before aggregation.
2572
+ // These are batch-invariant; the cursor's per-batch block bound is added in
2573
+ // `withCursorSql` below so the internal scan loop can advance it.
2574
+ const baseFilters: string[] = [`f.address = '${stablecoinDex}'`]
2575
+ if (taker !== undefined) baseFilters.push(`f.taker = '${taker}'`)
2576
+ // `tx_hash` is bloom-indexed on `dex_fills`, so this prunes granules.
2577
+ if (transactionHash !== undefined) baseFilters.push(`f.tx_hash = '${transactionHash}'`)
2578
+ if (fromBlock !== undefined) baseFilters.push(`f.block_num >= ${fromBlock}`)
2579
+ if (toBlock !== undefined) baseFilters.push(`f.block_num <= ${toBlock}`)
2580
+ // Normalize to ClickHouse's `YYYY-MM-DD HH:MM:SS` form — the ISO-Z form
2581
+ // rejects against `DateTime64(3, 'UTC')` with a conversion error.
2582
+ if (fromTimestamp !== undefined)
2583
+ baseFilters.push(`f.block_timestamp >= '${formatClickHouseDateTime(fromTimestamp)}'`)
2584
+ if (toTimestamp !== undefined)
2585
+ baseFilters.push(`f.block_timestamp <= '${formatClickHouseDateTime(toTimestamp)}'`)
2586
+
2587
+ // Group-level predicates: evaluated over each group's complete fill set, so
2588
+ // "any fill matches" semantics stay exact under SQL pagination. Like
2589
+ // `baseFilters`, these are batch-invariant; the cursor's group/row bound is
2590
+ // added per batch in `withCursorSql`.
2591
+ const baseGroupFilters: string[] = []
2592
+ if (maker !== undefined) baseGroupFilters.push(`max(f.maker = '${maker}') = 1`)
2593
+ if (participant !== undefined)
2594
+ baseGroupFilters.push(`max(f.taker = '${participant}' OR f.maker = '${participant}') = 1`)
2595
+
2596
+ // Per-batch SQL: the internal scan loop advances `activeCursor` across SQL
2597
+ // batches, so the cursor's bounds (row-level block bound in WHERE, group/row
2598
+ // refinement in HAVING) are rebuilt for each batch rather than baked into the
2599
+ // base predicates.
2600
+ const withCursorSql = (activeCursor: typeof cursor) => {
2601
+ const where = [...baseFilters]
2602
+ const group = [...baseGroupFilters]
2603
+ if (activeCursor !== undefined) {
2604
+ // The cursor's block bound is row-level; its `(block, first_idx)` tuple
2605
+ // refinement needs the aggregate and lives in HAVING.
2606
+ where.push(
2607
+ `f.block_num ${order === 'asc' ? '>=' : '<='} ${Cursor.literal(activeCursor[0]!, 'int')}`,
2608
+ )
2609
+ // Inclusive at the anchor group: a batch may end mid-group (a batched
2610
+ // transaction expands one group into several rows), so the anchor group
2611
+ // is re-fetched and its already-returned rows are dropped row-wise after
2612
+ // assembly.
2613
+ const op = order === 'asc' ? '>' : '<'
2614
+ const anchorBlock = Cursor.literal(activeCursor[0]!, 'int')
2615
+ const anchorGroup = Cursor.literal(activeCursor[1]!, 'int')
2616
+ group.push(
2617
+ `((block_num ${op} ${anchorBlock}) OR (block_num = ${anchorBlock} AND first_idx ${op}= ${anchorGroup}))`,
2618
+ )
2619
+ }
2620
+ return { having: group.length > 0 ? `HAVING ${group.join(' AND ')}` : '', where }
2621
+ }
2622
+
2623
+ return Timing.time(c, 'swaps', async () => {
2624
+ // One grouped query returns complete groups with their fills inline.
2625
+ // `toString(...)` forces `uint128`/`uint256` tuple members through
2626
+ // ClickHouse's JSON output as strings — raw numbers in tuples lose
2627
+ // precision past 2^53. `block_timestamp` is per-block, so `min(...)` is
2628
+ // the group timestamp.
2629
+ const fetchGroups = (activeCursor: typeof cursor, batchLimit: number, window: BlockWindow) => {
2630
+ const { having, where } = withCursorSql(activeCursor)
2631
+ return tidx.fetch({
2632
+ chainId,
2633
+ engine: 'clickhouse',
2634
+ query: `
2635
+ SELECT f.block_num AS block_num, f.tx_hash AS tx_hash, f.taker AS taker,
2636
+ min(f.log_idx) AS first_idx, min(f.block_timestamp) AS ts,
2637
+ groupArray(tuple(f.log_idx, toString(f."orderId"), f.maker,
2638
+ toString(f."amountFilled"), f."partialFill")) AS fills
2639
+ FROM dex_fills f
2640
+ WHERE ${[...where, `f.block_num >= ${window.lo}`, `f.block_num <= ${window.hi}`].join(' AND ')}
2641
+ GROUP BY f.block_num, f.tx_hash, f.taker
2642
+ ${having}
2643
+ ORDER BY block_num ${direction}, first_idx ${direction}
2644
+ LIMIT ${batchLimit}
2645
+ ` as string,
2646
+ })
2647
+ }
2648
+
2649
+ type Candidate = { group: parseGroupRow.Group; swap: z.output<typeof schema.getSwaps.Swap> }
2650
+
2651
+ // A row's keyset position is `(block, group first_idx, its first fill
2652
+ // logIndex)`; rows at or before the active cursor's row anchor were already
2653
+ // returned (the anchor group is re-fetched whole) and are dropped here.
2654
+ // The drop is purely positional.
2655
+ const past = (activeCursor: typeof cursor, blockNumber: number, logIndex: number) => {
2656
+ if (activeCursor === undefined) return true
2657
+ // Decode validated both fields as `int`, so they are plain numbers.
2658
+ const anchorBlock = Number(activeCursor[0])
2659
+ const anchorIndex = Number(activeCursor[2])
2660
+ if (blockNumber !== anchorBlock)
2661
+ return order === 'asc' ? blockNumber > anchorBlock : blockNumber < anchorBlock
2662
+ return order === 'asc' ? logIndex > anchorIndex : logIndex < anchorIndex
2663
+ }
2664
+
2665
+ // Advance the internal cursor past a whole fetched group so the next batch
2666
+ // re-fetches that group and `past()` drops all of its rows. The row anchor
2667
+ // is direction-aware: `asc` drops rows `<=` the group's max fill logIndex,
2668
+ // `desc` drops rows `>=` its min (`first_idx`). `group.fills` is sorted
2669
+ // ascending by `parseGroupRow`.
2670
+ const cursorAfterWholeGroup = (group: parseGroupRow.Group) =>
2671
+ [
2672
+ group.blockNumber,
2673
+ group.firstIndex,
2674
+ order === 'asc' ? group.fills.at(-1)!.logIndex : group.firstIndex,
2675
+ ] as const
2676
+
2677
+ // Resolve one fetched batch of groups into assembled, ordered candidate
2678
+ // rows. Only the state + quote lookups are needed to assemble; `mode`
2679
+ // (calldata) is deferred to the final page.
2680
+ const resolveBatch = async (
2681
+ groups: parseGroupRow.Group[],
2682
+ activeCursor: typeof cursor,
2683
+ ): Promise<Candidate[]> => {
2684
+ // Point-in-time order state (token/isBid/tick) for this batch's fills —
2685
+ // required to orient fills and assemble each swap's route ends.
2686
+ const blocks = [...new Set(groups.map((group) => group.blockNumber))]
2687
+ const states = await Timing.time(c, 'swap_states', () =>
2688
+ resolveOrderStates(c, {
2689
+ chainId,
2690
+ maxBlock: Math.max(...blocks),
2691
+ minBlock: Math.min(...blocks),
2692
+ orderIds: [
2693
+ ...new Set(groups.flatMap((group) => group.fills.map((fill) => fill.orderId))),
2694
+ ],
2695
+ }),
2696
+ )
2697
+
2698
+ // Each fill's book quote: from the `dex_pairs` index when present, else
2699
+ // the base token's on-chain `quoteToken()` — some books predate the
2700
+ // indexer's `PairCreated` decode (genesis system books) and have no
2701
+ // `dex_pairs` row.
2702
+ const bases = [
2703
+ ...new Set([...states.values()].flatMap((events) => events.map((event) => event.token))),
2704
+ ]
2705
+ const quoteByBase = new Map<string, z.output<typeof Schema.TokenAddress>>()
2706
+ for (const bookBase of bases) {
2707
+ const indexed = pairIndex.get(bookBase)
2708
+ if (indexed) quoteByBase.set(bookBase, indexed.quote)
2709
+ }
2710
+ const fallback = await Timing.time(c, 'swap_quotes', () =>
2711
+ resolveBookQuotes(c, {
2712
+ bases: bases.filter((bookBase) => !quoteByBase.has(bookBase)),
2713
+ chainId,
2714
+ }),
2715
+ )
2716
+ for (const [bookBase, quote] of fallback) quoteByBase.set(bookBase, quote)
2717
+
2718
+ // Assemble each group into swap rows — a batched transaction expands one
2719
+ // group into several rows — then drop already-returned rows of the
2720
+ // re-fetched anchor group (cursor resume); every surviving row is kept.
2721
+ const candidates: Candidate[] = []
2722
+ for (const group of groups)
2723
+ for (const swap of buildSwapRows(group, { order, quoteByBase, states })) {
2724
+ if (!past(activeCursor, group.blockNumber, swap.fills[0]!.logIndex)) continue
2725
+ // The `maker`/`participant` HAVING filters are group-level
2726
+ // (`(block, tx, taker)`), but a batched transaction expands one group
2727
+ // into several swap rows — only the rows whose own fills involve the
2728
+ // address satisfy the per-swap contract, so refine to row granularity
2729
+ // here. The accumulation loop tops the page back up to `limit`.
2730
+ if (maker !== undefined && !swap.fills.some((fill) => fill.maker === maker)) continue
2731
+ if (
2732
+ participant !== undefined &&
2733
+ swap.taker !== participant &&
2734
+ !swap.fills.some((fill) => fill.maker === participant)
2735
+ )
2736
+ continue
2737
+ candidates.push({ group, swap })
2738
+ }
2739
+ candidates.sort((a, b) => {
2740
+ const delta =
2741
+ a.group.blockNumber - b.group.blockNumber ||
2742
+ a.swap.fills[0]!.logIndex - b.swap.fills[0]!.logIndex
2743
+ return order === 'asc' ? delta : -delta
2744
+ })
2745
+ return candidates
2746
+ }
2747
+
2748
+ // A selective row predicate (`taker`/`maker`/`participant`/`transactionHash`)
2749
+ // makes matching fills sparse across a wide block range, so enumerate the
2750
+ // exact blocks holding a matching fill up front: it is cheap (bloom-indexed,
2751
+ // no `GROUP BY`/`groupArray`) and lets the window sweep jump straight to the
2752
+ // next populated block instead of paying a full grouped round-trip for every
2753
+ // empty `swapWindowBlocks` gap between clusters. The block list also yields
2754
+ // the scan bounds, so no separate `min`/`max` query is needed.
2755
+ //
2756
+ // The discovery predicate must be a *superset* of the grouped page query's
2757
+ // matches. The page query applies `maker`/`participant` as existential
2758
+ // HAVING aggregates over each `(block, tx, taker)` group, so a single group
2759
+ // can satisfy `maker` and `participant` via *different* fills. A row-level
2760
+ // conjunction would wrongly require one fill to satisfy both; OR the
2761
+ // group-level witnesses together instead — every block with a matching group
2762
+ // has at least one witness fill, and a group's fills share its block.
2763
+ const selective =
2764
+ taker !== undefined ||
2765
+ maker !== undefined ||
2766
+ participant !== undefined ||
2767
+ transactionHash !== undefined
2768
+ const blockFilters = [...baseFilters]
2769
+ {
2770
+ const witnesses: string[] = []
2771
+ if (maker !== undefined) witnesses.push(`f.maker = '${maker}'`)
2772
+ if (participant !== undefined) {
2773
+ witnesses.push(`f.taker = '${participant}'`)
2774
+ witnesses.push(`f.maker = '${participant}'`)
2775
+ }
2776
+ if (witnesses.length > 0) blockFilters.push(`(${witnesses.join(' OR ')})`)
2777
+ }
2778
+
2779
+ // Distinct matching blocks, ascending (binary-searched in `windowFrom`).
2780
+ // Only populated for selective queries; the unfiltered feed would match
2781
+ // nearly every block, so it keeps the `min`/`max` bound + fixed-stride sweep.
2782
+ let matchingBlocks: number[] | undefined
2783
+ const bounds = await Timing.time(c, 'swap_bounds', async () => {
2784
+ if (selective) {
2785
+ const result = await tidx.fetch({
2786
+ chainId,
2787
+ engine: 'clickhouse',
2788
+ query:
2789
+ `SELECT DISTINCT f.block_num AS block_num FROM dex_fills f WHERE ${blockFilters.join(' AND ')} ORDER BY block_num ASC` as string,
2790
+ })
2791
+ matchingBlocks = result.rows
2792
+ .map((row) => Value.toNumber(row['block_num']))
2793
+ .filter((block): block is number => block !== undefined)
2794
+ const lo = matchingBlocks[0]
2795
+ const hi = matchingBlocks.at(-1)
2796
+ return lo !== undefined && hi !== undefined ? { hi, lo } : undefined
2797
+ }
2798
+ const result = await tidx.fetch({
2799
+ chainId,
2800
+ engine: 'clickhouse',
2801
+ query:
2802
+ `SELECT min(f.block_num) AS lo, max(f.block_num) AS hi FROM dex_fills f WHERE ${blockFilters.join(' AND ')}` as string,
2803
+ })
2804
+ const row = result.rows[0]
2805
+ const lo = row ? Value.toNumber(row['lo']) : undefined
2806
+ const hi = row ? Value.toNumber(row['hi']) : undefined
2807
+ return lo !== undefined && hi !== undefined ? { hi, lo } : undefined
2808
+ })
2809
+
2810
+ const accumulated: Candidate[] = []
2811
+ if (bounds) {
2812
+ const lowerBound = Math.max(fromBlock ?? bounds.lo, bounds.lo)
2813
+ const upperBound = Math.min(toBlock ?? bounds.hi, bounds.hi)
2814
+
2815
+ // Window sweep: every grouped batch is bounded to one block window, since
2816
+ // an unbounded `dex_fills` GROUP BY is rejected by TIDX (HTTP 422) once the
2817
+ // table is large. `desc` walks windows downward from the cursor/head block;
2818
+ // `asc` upward. Within a window the keyset cursor advances by whole groups
2819
+ // (a full batch may leave more groups in the same window); a short batch
2820
+ // exhausts the window and slides the sweep to the next disjoint range.
2821
+ const batchLimit = limit + 1
2822
+ let activeCursor = cursor
2823
+ const cursorBlock = cursor !== undefined ? Number(cursor[0]) : undefined
2824
+
2825
+ // Snap an anchor block onto the matching-block list (selective queries):
2826
+ // the largest matching block `<= value` (desc) / smallest `>= value`
2827
+ // (asc). This collapses the empty gaps between fill clusters — `slideWindow`
2828
+ // jumps straight to the next populated block. `undefined` when no matching
2829
+ // block remains in range. `matchingBlocks` is ascending.
2830
+ const lastAtMost = (value: number): number | undefined => {
2831
+ if (matchingBlocks === undefined) return value
2832
+ let lo = 0
2833
+ let hi = matchingBlocks.length
2834
+ while (lo < hi) {
2835
+ const mid = (lo + hi) >> 1
2836
+ if (matchingBlocks[mid]! <= value) lo = mid + 1
2837
+ else hi = mid
2838
+ }
2839
+ return lo === 0 ? undefined : matchingBlocks[lo - 1]
2840
+ }
2841
+ const firstAtLeast = (value: number): number | undefined => {
2842
+ if (matchingBlocks === undefined) return value
2843
+ let lo = 0
2844
+ let hi = matchingBlocks.length
2845
+ while (lo < hi) {
2846
+ const mid = (lo + hi) >> 1
2847
+ if (matchingBlocks[mid]! < value) lo = mid + 1
2848
+ else hi = mid
2849
+ }
2850
+ return lo === matchingBlocks.length ? undefined : matchingBlocks[lo]
2851
+ }
2852
+
2853
+ const windowFrom = (anchor: number | undefined): BlockWindow | undefined => {
2854
+ if (order === 'desc') {
2855
+ const hi = lastAtMost(Math.min(upperBound, anchor ?? upperBound))
2856
+ return hi !== undefined && hi >= lowerBound
2857
+ ? { hi, lo: Math.max(lowerBound, hi - swapWindowBlocks + 1) }
2858
+ : undefined
2859
+ }
2860
+ const lo = firstAtLeast(Math.max(lowerBound, anchor ?? lowerBound))
2861
+ return lo !== undefined && lo <= upperBound
2862
+ ? { hi: Math.min(upperBound, lo + swapWindowBlocks - 1), lo }
2863
+ : undefined
2864
+ }
2865
+
2866
+ let window = windowFrom(cursorBlock)
2867
+
2868
+ // Slide to the next disjoint window away from the newest block and reset
2869
+ // the cursor: every row in the next window is already past the cursor
2870
+ // anchor in the requested order, so no anchor refinement is needed.
2871
+ const slideWindow = () => {
2872
+ activeCursor = undefined
2873
+ window =
2874
+ window === undefined
2875
+ ? undefined
2876
+ : windowFrom(order === 'desc' ? window.lo - 1 : window.hi + 1)
2877
+ }
2878
+
2879
+ while (accumulated.length <= limit && window !== undefined) {
2880
+ const result = await fetchGroups(activeCursor, batchLimit, window)
2881
+ const fetchedFull = result.rows.length >= batchLimit
2882
+
2883
+ const groups: parseGroupRow.Group[] = []
2884
+ for (const row of result.rows) {
2885
+ const group = parseGroupRow(row)
2886
+ if (group) groups.push(group)
2887
+ }
2888
+
2889
+ for (const candidate of await resolveBatch(groups, activeCursor)) {
2890
+ accumulated.push(candidate)
2891
+ if (accumulated.length > limit) break
2892
+ }
2893
+ if (accumulated.length > limit) break
2894
+
2895
+ // No parsable groups: a full batch cannot advance, so stop rather than
2896
+ // risk skipping lower groups in the same window; a short batch just
2897
+ // exhausts the window.
2898
+ const tail = groups.at(-1)
2899
+ if (tail === undefined) {
2900
+ if (fetchedFull) break
2901
+ slideWindow()
2902
+ continue
2903
+ }
2904
+
2905
+ // A short batch exhausts this window; a full one may leave more groups
2906
+ // in it, so advance the keyset cursor and re-query the same window.
2907
+ if (!fetchedFull) {
2908
+ slideWindow()
2909
+ continue
2910
+ }
2911
+ const next = cursorAfterWholeGroup(tail)
2912
+ // Progress guard: if the cursor cannot advance, slide instead of looping.
2913
+ if (
2914
+ activeCursor !== undefined &&
2915
+ Number(activeCursor[0]) === next[0] &&
2916
+ Number(activeCursor[1]) === next[1] &&
2917
+ Number(activeCursor[2]) === next[2]
2918
+ ) {
2919
+ slideWindow()
2920
+ continue
2921
+ }
2922
+ activeCursor = [next[0], next[1], next[2]]
2923
+ }
2924
+ }
2925
+
2926
+ // Cut to `limit`; the external cursor anchors at the last INCLUDED row so
2927
+ // the next call resumes exactly after it (the anchor group is re-fetched
2928
+ // and dropped row-wise). `> limit` means at least one more matching row
2929
+ // exists, so the page continues; otherwise the feed is exhausted.
2930
+ const pageRows = accumulated.slice(0, limit)
2931
+ const last = pageRows.at(-1)
2932
+ const nextCursor =
2933
+ accumulated.length > limit && last !== undefined
2934
+ ? Cursor.encode([
2935
+ last.group.blockNumber,
2936
+ last.group.firstIndex,
2937
+ last.swap.fills[0]!.logIndex,
2938
+ ])
2939
+ : null
2940
+
2941
+ const data = pageRows.map((candidate) => candidate.swap)
2942
+ // Recover each swap's `mode` from transaction calldata — deferred to here
2943
+ // so the candidate scan never pays the `txs` lookup for rows beyond the
2944
+ // page. Best-effort: a failed lookup leaves `mode: null`.
2945
+ await resolveSwapModes(c, { chainId, swaps: data })
2946
+
2947
+ return { data, nextCursor }
2948
+ })
2949
+ }
2950
+
2951
+ declare namespace getSwaps {
2952
+ /** Options for the swap page query. */
2953
+ type Options = {
2954
+ chainId: z.output<typeof Schema.ChainId>
2955
+ /** Opaque keyset cursor anchoring the page; omit for the head page. */
2956
+ cursor?: string | undefined
2957
+ fromBlock?: number | undefined
2958
+ fromTimestamp?: string | undefined
2959
+ limit: number
2960
+ /** Any-fill maker filter. */
2961
+ maker?: z.output<typeof Schema.Address> | undefined
2962
+ /** Sort direction over `(block_num, first fill log_idx)`. */
2963
+ order: 'asc' | 'desc'
2964
+ /** Pair index (base address → pair) used to orient each fill's book. */
2965
+ pairIndex: Map<string, getPairs.Row>
2966
+ /** Either-side filter — the taker or any fill's maker. */
2967
+ participant?: z.output<typeof Schema.Address> | undefined
2968
+ taker?: z.output<typeof Schema.Address> | undefined
2969
+ toBlock?: number | undefined
2970
+ toTimestamp?: string | undefined
2971
+ /** Only swaps contained in this transaction. */
2972
+ transactionHash?: z.output<typeof Schema.Hash> | undefined
2973
+ }
2974
+ }
2975
+
2976
+ /**
2977
+ * Validates and shapes one grouped page row — the `(block, tx, taker)` key
2978
+ * plus its `groupArray` fill tuples — into a typed group. Fills are deduped
2979
+ * by `log_idx` (ReplacingMergeTree can surface unmerged duplicate rows) and
2980
+ * sorted into execution order. Returns undefined for malformed rows so the
2981
+ * caller can drop them without failing the page.
2982
+ */
2983
+ function parseGroupRow(row: Record<string, unknown>): parseGroupRow.Group | undefined {
2984
+ const blockNumber = Value.toNumber(row['block_num'])
2985
+ const firstIndex = Value.toNumber(row['first_idx'])
2986
+ const taker = Schema.Address.safeParse(row['taker'])
2987
+ const timestamp = Value.toIsoDateTime(row['ts'])
2988
+ const transactionHash = Schema.Hash.safeParse(row['tx_hash'])
2989
+ const tuples = row['fills']
2990
+ if (
2991
+ blockNumber === undefined ||
2992
+ firstIndex === undefined ||
2993
+ !taker.success ||
2994
+ timestamp === undefined ||
2995
+ !transactionHash.success ||
2996
+ !Array.isArray(tuples)
2997
+ )
2998
+ return undefined
2999
+
3000
+ const fills = new Map<number, parseGroupRow.Fill>()
3001
+ for (const tuple of tuples) {
3002
+ if (!Array.isArray(tuple)) continue
3003
+ const logIndex = Value.toNumber(tuple[0])
3004
+ const orderId = Value.toIntegerString(tuple[1])
3005
+ const maker = Schema.Address.safeParse(tuple[2])
3006
+ const amountFilled = Value.toIntegerString(tuple[3])
3007
+ if (
3008
+ logIndex === undefined ||
3009
+ orderId === undefined ||
3010
+ !maker.success ||
3011
+ amountFilled === undefined
3012
+ )
3013
+ continue
3014
+ fills.set(logIndex, {
3015
+ amountFilled,
3016
+ logIndex,
3017
+ maker: maker.data,
3018
+ orderId,
3019
+ // ClickHouse surfaces booleans as `0`/`1`.
3020
+ partialFill: tuple[4] === true || tuple[4] === 1,
3021
+ })
3022
+ }
3023
+ if (fills.size === 0) return undefined
3024
+
3025
+ return {
3026
+ blockNumber,
3027
+ fills: [...fills.values()].sort((a, b) => a.logIndex - b.logIndex),
3028
+ firstIndex,
3029
+ taker: taker.data,
3030
+ timestamp,
3031
+ transactionHash: transactionHash.data,
3032
+ }
3033
+ }
3034
+
3035
+ declare namespace parseGroupRow {
3036
+ /** One raw `OrderFilled` fill within a group, before order-state resolution. */
3037
+ type Fill = {
3038
+ /** Base-token amount filled, as a decimal integer string. */
3039
+ amountFilled: string
3040
+ logIndex: number
3041
+ maker: z.output<typeof Schema.Address>
3042
+ /** On-chain order identifier (decimal string). */
3043
+ orderId: string
3044
+ partialFill: boolean
3045
+ }
3046
+
3047
+ /** One `(block, transaction, taker)` group of fills. */
3048
+ type Group = {
3049
+ blockNumber: number
3050
+ /** The group's fills, deduped and sorted by `logIndex`. */
3051
+ fills: Fill[]
3052
+ /** Lowest fill `logIndex` — the group's keyset cursor position. */
3053
+ firstIndex: number
3054
+ taker: z.output<typeof Schema.Address>
3055
+ /** Block timestamp (ISO). */
3056
+ timestamp: string
3057
+ transactionHash: z.output<typeof Schema.Hash>
3058
+ }
3059
+ }
3060
+
3061
+ /**
3062
+ * Resolves the point-in-time order-state streams for a set of `orderId`s
3063
+ * from the raw `OrderPlaced`/`OrderFlipped` logs.
3064
+ *
3065
+ * A T5+ flip order keeps its `orderId` while `(isBid, tick)` swap sides on
3066
+ * every flip, so a fill's state is the latest event *before* the fill — not
3067
+ * the original `OrderPlaced`. One query returns, per order, its pre-page
3068
+ * history compressed to the latest event (`argMax`, sentinel position
3069
+ * `(-1, -1)` so it sorts first) plus every raw in-page event, so an order
3070
+ * that flips between two of its page fills resolves each fill correctly.
3071
+ * `logs` is sorted by `(address, selector, block_num, log_idx)` with a
3072
+ * `topic1` bloom index, so both halves are pruned range reads. Returned
3073
+ * per-order event lists are position-sorted.
3074
+ */
3075
+ export async function resolveOrderStates(
3076
+ c: Context<App.Environment>,
3077
+ options: resolveOrderStates.Options,
3078
+ ): Promise<Map<string, resolveOrderStates.State[]>> {
3079
+ const { chainId, maxBlock, minBlock, orderIds } = options
3080
+ const map = new Map<string, resolveOrderStates.State[]>()
3081
+ if (orderIds.length === 0) return map
3082
+ const tidx = c.get('getTidx')(chainId)
3083
+
3084
+ const topics = orderIds.map((id) => `'0x${BigInt(id).toString(16).padStart(64, '0')}'`)
3085
+ const eventFilter = `address = '${stablecoinDex}'
3086
+ AND selector IN ('${orderPlacedTopic}', '${orderFlippedTopic}')
3087
+ AND topic1 IN (${topics.join(', ')})`
3088
+ // Aliases must not shadow the source columns (`t3`, not `topic3`) —
3089
+ // ClickHouse rejects self-shadowing aliases in queries that also filter on
3090
+ // the shadowed column.
3091
+ const result = await tidx.fetch({
3092
+ chainId,
3093
+ engine: 'clickhouse',
3094
+ query: `
3095
+ SELECT topic1, argMax(topic3, (block_num, log_idx)) AS t3,
3096
+ argMax(data, (block_num, log_idx)) AS payload,
3097
+ toInt64(-1) AS bn, toInt32(-1) AS li
3098
+ FROM logs
3099
+ WHERE ${eventFilter} AND block_num < ${minBlock}
3100
+ GROUP BY topic1
3101
+ UNION ALL
3102
+ SELECT topic1, topic3 AS t3, data AS payload, block_num AS bn, log_idx AS li
3103
+ FROM logs
3104
+ WHERE ${eventFilter} AND block_num >= ${minBlock} AND block_num <= ${maxBlock}
3105
+ ` as string,
3106
+ })
3107
+
3108
+ for (const row of result.rows) {
3109
+ const parsed = parseStateRow(row)
3110
+ if (!parsed) continue
3111
+ const events = map.get(parsed.orderId)
3112
+ if (events) events.push(parsed.state)
3113
+ else map.set(parsed.orderId, [parsed.state])
3114
+ }
3115
+ for (const events of map.values())
3116
+ events.sort((a, b) => a.blockNumber - b.blockNumber || a.logIndex - b.logIndex)
3117
+ return map
3118
+ }
3119
+
3120
+ declare namespace resolveOrderStates {
3121
+ /** Options for the order-state stream query. */
3122
+ type Options = {
3123
+ chainId: z.output<typeof Schema.ChainId>
3124
+ /** Highest block of the page (inclusive in-span bound). */
3125
+ maxBlock: number
3126
+ /** Lowest block of the page (baseline/in-span split point). */
3127
+ minBlock: number
3128
+ /** Decimal order ids to resolve. */
3129
+ orderIds: readonly string[]
3130
+ }
3131
+
3132
+ /** Order state effective from `(blockNumber, logIndex)` onward. */
3133
+ type State = {
3134
+ /** Event block, or `-1` for the compressed pre-page baseline. */
3135
+ blockNumber: number
3136
+ /** Maker side at this state: `true` = bid (taker sells base into it). */
3137
+ isBid: boolean
3138
+ /** Event log index, or `-1` for the baseline. */
3139
+ logIndex: number
3140
+ /** Signed price tick at this state. */
3141
+ tick: number
3142
+ /** Book base token (lowercase address). */
3143
+ token: string
3144
+ }
3145
+ }
3146
+
3147
+ /**
3148
+ * Decodes one order-state row (raw log topics/data) into a positioned
3149
+ * `(token, isBid, tick)` state. `OrderPlaced` and `OrderFlipped` share the
3150
+ * layout of the words read here: `data` word 1 is `isBid`, word 2 is `tick`
3151
+ * (int16, sign-extended); the book base token is `topic3`.
3152
+ */
3153
+ function parseStateRow(
3154
+ row: Record<string, unknown>,
3155
+ ): { orderId: string; state: resolveOrderStates.State } | undefined {
3156
+ const topic1 = Value.toText(row['topic1'])
3157
+ const topic3 = Value.toText(row['t3'])
3158
+ const payload = Value.toText(row['payload'])
3159
+ const blockNumber = Value.toNumber(row['bn'])
3160
+ const logIndex = Value.toNumber(row['li'])
3161
+ if (
3162
+ topic1 === undefined ||
3163
+ !/^0x[0-9a-fA-F]{64}$/.test(topic1) ||
3164
+ topic3 === undefined ||
3165
+ !/^0x[0-9a-fA-F]{64}$/.test(topic3) ||
3166
+ payload === undefined ||
3167
+ payload.length < 2 + 64 * 3 ||
3168
+ blockNumber === undefined ||
3169
+ logIndex === undefined
3170
+ )
3171
+ return undefined
3172
+
3173
+ const isBid = BigInt(`0x${payload.slice(66, 130)}`) !== 0n
3174
+ // int16 sign-extended across the 32-byte word: values ≥ 2^255 are negative.
3175
+ const tickWord = BigInt(`0x${payload.slice(130, 194)}`)
3176
+ const tick = Number(tickWord >= 2n ** 255n ? tickWord - 2n ** 256n : tickWord)
3177
+ return {
3178
+ orderId: BigInt(topic1).toString(),
3179
+ state: {
3180
+ blockNumber,
3181
+ isBid,
3182
+ logIndex,
3183
+ tick,
3184
+ token: `0x${topic3.slice(26).toLowerCase()}`,
3185
+ },
3186
+ }
3187
+ }
3188
+
3189
+ /**
3190
+ * Recovers the decoded swap calls of a page's transactions from calldata,
3191
+ * keyed by transaction hash — the source of the swap-level `mode`. Direct
3192
+ * calls decode from `txs.input`; AA (type `0x76`) transactions use the
3193
+ * indexer's pre-decoded `calls` JSON column (`[{to, value, input}]`), so no
3194
+ * envelope parsing is needed.
3195
+ *
3196
+ * The `block_num IN (...)` bound is required: `txs` is sorted by
3197
+ * `(block_num, idx)` with no hash index, so a bare `hash IN` lookup
3198
+ * full-scans the table and times out upstream.
3199
+ */
3200
+ async function resolveSwapCalls(
3201
+ c: Context<App.Environment>,
3202
+ options: resolveSwapCalls.Options,
3203
+ ): Promise<Map<string, resolveSwapCalls.Call[]>> {
3204
+ const { blocks, chainId, hashes } = options
3205
+ const map = new Map<string, resolveSwapCalls.Call[]>()
3206
+ if (hashes.length === 0) return map
3207
+ const tidx = c.get('getTidx')(chainId)
3208
+
3209
+ const result = await tidx.fetch({
3210
+ chainId,
3211
+ engine: 'clickhouse',
3212
+ query: `
3213
+ SELECT hash, "type", "to", input, calls
3214
+ FROM txs
3215
+ WHERE block_num IN (${blocks.join(', ')})
3216
+ AND hash IN (${hashes.map((hash) => `'${hash}'`).join(', ')})
3217
+ ` as string,
3218
+ })
3219
+
3220
+ for (const row of result.rows) {
3221
+ const hash = Value.toText(row['hash'])
3222
+ if (hash === undefined) continue
3223
+ // AA transactions carry their batched calls in the pre-decoded JSON
3224
+ // column; every other type is a single top-level call.
3225
+ type InnerCall = { data?: unknown; input?: unknown; to?: unknown }
3226
+ const inner: InnerCall[] = (() => {
3227
+ if (Value.toNumber(row['type']) !== 0x76) return [{ input: row['input'], to: row['to'] }]
3228
+ try {
3229
+ const raw = row['calls']
3230
+ const parsed: unknown = typeof raw === 'string' ? JSON.parse(raw) : raw
3231
+ return Array.isArray(parsed) ? (parsed as InnerCall[]) : []
3232
+ } catch {
3233
+ return []
3234
+ }
3235
+ })()
3236
+
3237
+ const calls: resolveSwapCalls.Call[] = []
3238
+ for (const call of inner) {
3239
+ const to = Value.toText(call.to)
3240
+ const data = Value.toText(call.input) ?? Value.toText(call.data)
3241
+ if (to?.toLowerCase() !== stablecoinDex || data === undefined) continue
3242
+ try {
3243
+ const decoded = decodeFunctionData({ abi: swapAbi, data: data as Hex.Hex })
3244
+ calls.push({
3245
+ mode: decoded.functionName === 'swapExactAmountIn' ? 'exactSource' : 'exactDestination',
3246
+ tokenIn: decoded.args[0].toLowerCase(),
3247
+ tokenOut: decoded.args[1].toLowerCase(),
3248
+ })
3249
+ } catch {
3250
+ // Not a swap call (place/cancel/withdraw/...); skip.
3251
+ }
3252
+ }
3253
+ if (calls.length > 0) map.set(hash, calls)
3254
+ }
3255
+ return map
3256
+ }
3257
+
3258
+ declare namespace resolveSwapCalls {
3259
+ /** One decoded swap call: the pinned-amount mode plus its route ends. */
3260
+ type Call = {
3261
+ /** `exactSource` = `swapExactAmountIn`; `exactDestination` = `swapExactAmountOut`. */
3262
+ mode: 'exactSource' | 'exactDestination'
3263
+ /** Call `tokenIn` (lowercase). */
3264
+ tokenIn: string
3265
+ /** Call `tokenOut` (lowercase). */
3266
+ tokenOut: string
3267
+ }
3268
+
3269
+ /** Options for the page txs lookup. */
3270
+ type Options = {
3271
+ /** The page's block numbers (prunes the `txs` sort-key scan). */
3272
+ blocks: readonly number[]
3273
+ chainId: z.output<typeof Schema.ChainId>
3274
+ /** The page's transaction hashes. */
3275
+ hashes: readonly string[]
3276
+ }
3277
+ }
3278
+
3279
+ /**
3280
+ * Attaches the swap-level `mode` (exact-source vs exact-destination) to each
3281
+ * row in place, recovered from the decoded transaction calldata
3282
+ * ({@link resolveSwapCalls}). A row's mode is pinned when one or more matching
3283
+ * calls (`tokenIn = route start`, `tokenOut = route end`) all agree; otherwise
3284
+ * it stays `null` (a resting order that crossed on placement, undecodable
3285
+ * calldata, or a failed lookup). Mutates `swaps`; runs only on the final page,
3286
+ * so the candidate scan never pays this `txs` lookup for filtered-out rows.
3287
+ */
3288
+ async function resolveSwapModes(
3289
+ c: Context<App.Environment>,
3290
+ options: resolveSwapModes.Options,
3291
+ ): Promise<void> {
3292
+ const { chainId, swaps } = options
3293
+ if (swaps.length === 0) return
3294
+ const calls = await Timing.time(c, 'swap_calls', () =>
3295
+ resolveSwapCalls(c, {
3296
+ blocks: [...new Set(swaps.map((swap) => swap.blockNumber))],
3297
+ chainId,
3298
+ hashes: [...new Set(swaps.map((swap) => swap.transactionHash))],
3299
+ }),
3300
+ ).catch(() => new Map<string, resolveSwapCalls.Call[]>())
3301
+
3302
+ for (const swap of swaps) {
3303
+ const matched = (calls.get(swap.transactionHash) ?? []).filter(
3304
+ (call) =>
3305
+ call.tokenIn === swap.sourceToken.address &&
3306
+ call.tokenOut === swap.destinationToken.address,
3307
+ )
3308
+ swap.mode =
3309
+ matched.length > 0 && matched.every((call) => call.mode === matched[0]!.mode)
3310
+ ? matched[0]!.mode
3311
+ : null
3312
+ }
3313
+ }
3314
+
3315
+ declare namespace resolveSwapModes {
3316
+ /** Options for the deferred swap-mode resolution. */
3317
+ type Options = {
3318
+ chainId: z.output<typeof Schema.ChainId>
3319
+ /** The final page's swap rows; `mode` is mutated in place. */
3320
+ swaps: z.output<typeof schema.getSwaps.Swap>[]
3321
+ }
3322
+ }
3323
+
3324
+ /**
3325
+ * Resolves the quote token of order books missing from the `dex_pairs` index
3326
+ * (books created without a `PairCreated` event, e.g. genesis system books)
3327
+ * by reading the base token's on-chain `quoteToken()` — the quote is
3328
+ * intrinsic and immutable for a TIP-20, so results memoize long. `undefined`
3329
+ * from the RPC means the default quote (pathUSD). Best-effort: a base whose
3330
+ * quote cannot be resolved is absent from the returned map, and its fills
3331
+ * are dropped downstream.
3332
+ */
3333
+ export async function resolveBookQuotes(
3334
+ c: Context<App.Environment>,
3335
+ options: resolveBookQuotes.Options,
3336
+ ): Promise<Map<string, z.output<typeof Schema.TokenAddress>>> {
3337
+ const { bases, chainId } = options
3338
+ const getClient = c.get('getClient')
3339
+ const store = c.get('store')
3340
+ const map = new Map<string, z.output<typeof Schema.TokenAddress>>()
3341
+ if (bases.length === 0) return map
3342
+
3343
+ await Promise.all(
3344
+ bases.map(async (base) => {
3345
+ try {
3346
+ const quote = await Store.memoize(
3347
+ async () => {
3348
+ const metadata = await getClient(chainId).token.getMetadata({
3349
+ token: base as Address,
3350
+ })
3351
+ return metadata.quoteToken?.toLowerCase() ?? Addresses.pathUsd
3352
+ },
3353
+ { key: `exchange:v1:${chainId}:book-quote:${base}`, store, ttl: Ttl.minutes(60) },
3354
+ )
3355
+ const parsed = Schema.TokenAddress.safeParse(quote)
3356
+ if (parsed.success) map.set(base, parsed.data)
3357
+ } catch {
3358
+ // Best-effort: unresolvable books drop their fills downstream.
3359
+ }
3360
+ }),
3361
+ )
3362
+ return map
3363
+ }
3364
+
3365
+ declare namespace resolveBookQuotes {
3366
+ /** Options for the book-quote fallback resolution. */
3367
+ type Options = {
3368
+ /** Book base addresses missing from the pair index (lowercase). */
3369
+ bases: readonly string[]
3370
+ chainId: z.output<typeof Schema.ChainId>
3371
+ }
3372
+ }
3373
+
3374
+ /**
3375
+ * Assembles one group's fills into public swap rows.
3376
+ *
3377
+ * Each fill is oriented from the taker's perspective using its point-in-time
3378
+ * order state (maker bid → the taker sold the book base; maker ask → bought
3379
+ * it; the quote side is reconstructed from the tick: `1 base =
3380
+ * (priceScale + tick) / priceScale quote`). Oriented fills bucket into legs
3381
+ * (one book + direction), and legs chain source → destination into rows:
3382
+ *
3383
+ * - one (possibly multi-hop) swap → one chain → one row;
3384
+ * - unrelated swaps batched in one transaction → separate rows;
3385
+ * - a token-wise chaining batch is indistinguishable from one multi-hop swap
3386
+ * and merges (documented trade-off);
3387
+ * - cyclic batches that fit no linear chain degrade to one row per leg.
3388
+ *
3389
+ * Fills with no resolvable state (historical `orderId = 0` ghost fills) or an
3390
+ * unknown book are dropped; a group may therefore produce no rows.
3391
+ */
3392
+ function buildSwapRows(
3393
+ group: parseGroupRow.Group,
3394
+ options: buildSwapRows.Options,
3395
+ ): z.output<typeof schema.getSwaps.Swap>[] {
3396
+ const { order, quoteByBase, states } = options
3397
+ const priceScale = BigInt(Tick.priceScale)
3398
+
3399
+ type Oriented = {
3400
+ destination: { address: z.output<typeof Schema.TokenAddress>; amount: bigint }
3401
+ fill: z.output<typeof schema.getSwaps.Fill>
3402
+ source: { address: z.output<typeof Schema.TokenAddress>; amount: bigint }
3403
+ }
3404
+ const oriented: Oriented[] = []
3405
+ for (const fill of group.fills) {
3406
+ // Latest state event strictly before the fill position. The flip a fill
3407
+ // itself triggers logs *after* the fill, so it never applies to its own
3408
+ // fill; the baseline (argMax) event carries position `(-1, -1)`.
3409
+ const events = states.get(fill.orderId)
3410
+ if (!events) continue
3411
+ let state: resolveOrderStates.State | undefined
3412
+ for (let i = events.length - 1; i >= 0; i--) {
3413
+ const event = events[i]!
3414
+ if (
3415
+ event.blockNumber < group.blockNumber ||
3416
+ (event.blockNumber === group.blockNumber && event.logIndex < fill.logIndex)
3417
+ ) {
3418
+ state = event
3419
+ break
3420
+ }
3421
+ }
3422
+ if (!state) continue
3423
+ const baseAddress = Schema.TokenAddress.safeParse(state.token)
3424
+ const quoteAddress = quoteByBase.get(state.token)
3425
+ if (!baseAddress.success || quoteAddress === undefined) continue
3426
+
3427
+ // `amountFilled` is denominated in the book base; reconstruct the quote
3428
+ // side from the tick.
3429
+ const baseAmount = BigInt(fill.amountFilled)
3430
+ const quoteAmount = (baseAmount * (priceScale + BigInt(state.tick))) / priceScale
3431
+ const base = { address: baseAddress.data, amount: baseAmount }
3432
+ const quote = { address: quoteAddress, amount: quoteAmount }
3433
+ const source = state.isBid ? base : quote
3434
+ const destination = state.isBid ? quote : base
3435
+ oriented.push({
3436
+ destination,
3437
+ fill: {
3438
+ destinationToken: { address: destination.address, amount: destination.amount.toString() },
3439
+ logIndex: fill.logIndex,
3440
+ maker: fill.maker,
3441
+ orderId: fill.orderId,
3442
+ partialFill: fill.partialFill,
3443
+ price: Tick.toPrice(state.tick),
3444
+ sourceToken: { address: source.address, amount: source.amount.toString() },
3445
+ },
3446
+ source,
3447
+ })
3448
+ }
3449
+ if (oriented.length === 0) return []
3450
+
3451
+ // Bucket fills into legs: one (source → destination) edge per book +
3452
+ // direction, amounts summed.
3453
+ const legs = new Map<string, buildSwapRows.Leg>()
3454
+ for (const item of oriented) {
3455
+ const key = `${item.source.address}>${item.destination.address}`
3456
+ const leg = legs.get(key)
3457
+ if (leg) {
3458
+ leg.destinationAmount += item.destination.amount
3459
+ leg.fills.push(item.fill)
3460
+ leg.sourceAmount += item.source.amount
3461
+ } else
3462
+ legs.set(key, {
3463
+ destination: item.destination.address,
3464
+ destinationAmount: item.destination.amount,
3465
+ fills: [item.fill],
3466
+ source: item.source.address,
3467
+ sourceAmount: item.source.amount,
3468
+ })
3469
+ }
3470
+
3471
+ // Chain legs source → destination; each maximal chain is one swap row.
3472
+ // When no chain head exists (a cyclic batch), fall back to one row per leg.
3473
+ const remaining = new Set(legs.values())
3474
+ const chains: buildSwapRows.Leg[][] = []
3475
+ while (remaining.size > 0) {
3476
+ const pool = [...remaining]
3477
+ const head = pool.find(
3478
+ (leg) => !pool.some((other) => other !== leg && other.destination === leg.source),
3479
+ )
3480
+ if (!head) {
3481
+ for (const leg of pool) chains.push([leg])
3482
+ break
3483
+ }
3484
+ const chain = [head]
3485
+ remaining.delete(head)
3486
+ let current = head
3487
+ while (true) {
3488
+ const next = [...remaining].find((leg) => leg.source === current.destination)
3489
+ if (!next) break
3490
+ chain.push(next)
3491
+ remaining.delete(next)
3492
+ current = next
3493
+ }
3494
+ chains.push(chain)
3495
+ }
3496
+
3497
+ const rows = chains.map((chain) => {
3498
+ const first = chain[0]!
3499
+ const last = chain.at(-1)!
3500
+ const fills = chain.flatMap((leg) => leg.fills).sort((a, b) => a.logIndex - b.logIndex)
3501
+ const logIndex = fills[0]!.logIndex
3502
+ // `mode` is recovered from transaction calldata and attached later
3503
+ // ({@link resolveSwapModes}), only for the rows that make the final page —
3504
+ // so the calldata lookup stays off the candidate scan's hot path.
3505
+ return {
3506
+ blockNumber: group.blockNumber,
3507
+ destinationToken: { address: last.destination, amount: last.destinationAmount.toString() },
3508
+ filledAt: group.timestamp,
3509
+ fills,
3510
+ id: `${group.transactionHash}-${logIndex}`,
3511
+ logIndex,
3512
+ mode: null as 'exactSource' | 'exactDestination' | null,
3513
+ rate: formatRate(last.destinationAmount, first.sourceAmount),
3514
+ route: [first.source, ...chain.map((leg) => leg.destination)],
3515
+ sourceToken: { address: first.source, amount: first.sourceAmount.toString() },
3516
+ taker: group.taker,
3517
+ transactionHash: group.transactionHash,
3518
+ }
3519
+ })
3520
+
3521
+ // Order a multi-row group by each row's first fill, in page direction.
3522
+ const firstIndex = (row: (typeof rows)[number]) => row.fills[0]!.logIndex
3523
+ rows.sort((a, b) =>
3524
+ order === 'asc' ? firstIndex(a) - firstIndex(b) : firstIndex(b) - firstIndex(a),
3525
+ )
3526
+ return rows
3527
+ }
3528
+
3529
+ declare namespace buildSwapRows {
3530
+ /** A directed swap leg: every fill on one book in one taker direction. */
3531
+ type Leg = {
3532
+ /** Token the taker received on this leg. */
3533
+ destination: z.output<typeof Schema.TokenAddress>
3534
+ /** Total received on this leg, in base units. */
3535
+ destinationAmount: bigint
3536
+ /** The leg's fills (public shape). */
3537
+ fills: z.output<typeof schema.getSwaps.Fill>[]
3538
+ /** Token the taker sent on this leg. */
3539
+ source: z.output<typeof Schema.TokenAddress>
3540
+ /** Total sent on this leg, in base units. */
3541
+ sourceAmount: bigint
3542
+ }
3543
+
3544
+ /** Per-group assembly inputs. */
3545
+ type Options = {
3546
+ /** Page sort direction (orders multi-row groups). */
3547
+ order: 'asc' | 'desc'
3548
+ /** Each book's quote token, keyed by its base address. */
3549
+ quoteByBase: Map<string, z.output<typeof Schema.TokenAddress>>
3550
+ /** Point-in-time order-state streams by orderId. */
3551
+ states: Map<string, resolveOrderStates.State[]>
3552
+ }
3553
+ }
3554
+
3555
+ /**
3556
+ * Formats a destination/source amount ratio as a fixed-decimal string at the
3557
+ * DEX price scale (5 dp), via bigint math so large amounts don't lose float
3558
+ * precision.
3559
+ */
3560
+ function formatRate(destination: bigint, source: bigint): string {
3561
+ const scale = BigInt(Tick.priceScale)
3562
+ const decimals = String(Tick.priceScale).length - 1
3563
+ if (source === 0n) return `0.${'0'.repeat(decimals)}`
3564
+ const scaled = (destination * scale) / source
3565
+ return `${scaled / scale}.${(scaled % scale).toString().padStart(decimals, '0')}`
3566
+ }
3567
+
3568
+ /**
3569
+ * Formats the taker-perspective rate (destination/source) for a fill of an
3570
+ * order at `tick`. For maker bids that is the DEX price (quote per base); for
3571
+ * asks it is its inverse, formatted to the same 5 dp as the price scale.
3572
+ */
3573
+ function ohlcRate(order: { isBid: boolean; tick: number }): string {
3574
+ if (order.isBid) return Tick.toPrice(order.tick)
3575
+ const priceScale = BigInt(Tick.priceScale)
3576
+ return (
3577
+ Number(priceScale * priceScale) /
3578
+ Number(priceScale + BigInt(order.tick)) /
3579
+ Number(priceScale)
3580
+ ).toFixed(5)
3581
+ }
3582
+
3583
+ /**
3584
+ * Parses a `(tick, isBid)` tuple returned by a ClickHouse `argMin`/`argMax`
3585
+ * aggregate (JSON renders tuples as arrays; both members are small integers,
3586
+ * so no string forcing is needed).
3587
+ */
3588
+ function parseTickSide(value: unknown): { isBid: boolean; tick: number } | undefined {
3589
+ if (!Array.isArray(value)) return undefined
3590
+ const tick = Value.toNumber(value[0])
3591
+ if (tick === undefined) return undefined
3592
+ return { isBid: value[1] === 1 || value[1] === true, tick }
3593
+ }
3594
+
3595
+ /**
3596
+ * Resolves a candlestick (OHLC) aggregation for a single pair over a
3597
+ * rolling window.
3598
+ *
3599
+ * The candles are aggregated **in ClickHouse**: each bucket folds server-side
3600
+ * (open/close by fill position, high/low by executed rate, volume sums and
3601
+ * fill counts), so the response covers the entire requested window no matter
3602
+ * how many fills it contains — the previous implementation shipped the newest
3603
+ * raw fills to the app capped at 1,000, which truncated busy pairs' 7d/30d
3604
+ * windows to a few hours. The pair filter still resolves through the
3605
+ * OrderPlaced-only `dex_orders` table, so fills of flipped orders remain
3606
+ * invisible until order state lands at ingest (the `dex_ohlc_1m` plan).
3607
+ *
3608
+ * `OrderFilled` only carries the base-token `amountFilled`; the quote-side
3609
+ * amount is reconstructed per fill from the order's tick using the DEX's
3610
+ * fixed price scale (`quote = base * (priceScale + tick) / priceScale`),
3611
+ * which assumes base and quote share decimals — true for the stablecoin DEX.
3612
+ *
3613
+ * Buckets are anchored against epoch (`intDiv(ts, intervalMs)`), so a `1h`
3614
+ * bucket at `13:00–14:00` aligns to the wall clock regardless of when the
3615
+ * request was made. Empty buckets are dropped; charting UIs that need a
3616
+ * continuous axis can forward-fill from the previous close. `data` is
3617
+ * returned oldest → newest, which is the canonical chart input order.
3618
+ */
3619
+ async function getPairOhlc(c: Context<App.Environment>, options: getPairOhlc.Options) {
3620
+ const { chainId, interval, pair, window } = options
3621
+ const tidx = c.get('getTidx')(chainId)
3622
+ // ClickHouse `DateTime64(3, 'UTC')` rejects ISO-Z; normalize to space form.
3623
+ const fromTimestamp = formatClickHouseDateTime(
3624
+ new Date(Date.now() - windowMs(window)).toISOString(),
3625
+ )
3626
+ const bucketMs = intervalMs(interval)
3627
+
3628
+ return Timing.time(c, 'pair_ohlc', async () => {
3629
+ // Taker-perspective rate used only to *select* the high/low fill; the
3630
+ // formatted rate string is recomputed in JS from the winning `(tick,
3631
+ // isBid)` so it goes through the exact same formatting as open/close.
3632
+ const scale = Tick.priceScale
3633
+ const rate = `if(p."isBid" = 1, (${scale} + p.tick) / ${scale}, ${scale} / (${scale} + p.tick))`
3634
+ // UInt256 sums are forced through `toString` so values past 2^53 survive
3635
+ // JSON; the `(tick, isBid)` tuples are small integers and need no forcing.
3636
+ const result = await tidx.fetch({
3637
+ chainId,
3638
+ engine: 'clickhouse',
3639
+ query: `
3640
+ SELECT
3641
+ intDiv(toUnixTimestamp64Milli(f.block_timestamp), ${bucketMs}) AS bucket,
3642
+ count() AS fill_count,
3643
+ toString(sum(f."amountFilled")) AS base_volume,
3644
+ toString(sum(intDiv(f."amountFilled" * toUInt256(${scale} + p.tick), ${scale}))) AS quote_volume,
3645
+ argMin(tuple(p.tick, p."isBid"), tuple(f.block_num, f.log_idx)) AS open_t,
3646
+ argMax(tuple(p.tick, p."isBid"), tuple(f.block_num, f.log_idx)) AS close_t,
3647
+ argMax(tuple(p.tick, p."isBid"), ${rate}) AS high_t,
3648
+ argMin(tuple(p.tick, p."isBid"), ${rate}) AS low_t
3649
+ FROM dex_fills f
3650
+ JOIN dex_orders p ON p."orderId" = f."orderId"
3651
+ WHERE f.address = '${stablecoinDex}'
3652
+ AND f.block_timestamp >= '${fromTimestamp}'
3653
+ AND f."orderId" IN (
3654
+ SELECT p2."orderId" FROM dex_orders p2 WHERE p2.token = '${pair.base}'
3655
+ )
3656
+ GROUP BY bucket
3657
+ ORDER BY bucket ASC
3658
+ ` as string,
3659
+ })
3660
+
3661
+ const data: z.output<typeof schema.getPairOhlc.Bucket>[] = []
3662
+ for (const row of result.rows) {
3663
+ const bucket = Value.toNumber(row['bucket'])
3664
+ const fillCount = Value.toNumber(row['fill_count'])
3665
+ const base = Value.toIntegerString(row['base_volume'])
3666
+ const quote = Value.toIntegerString(row['quote_volume'])
3667
+ const open = parseTickSide(row['open_t'])
3668
+ const close = parseTickSide(row['close_t'])
3669
+ const high = parseTickSide(row['high_t'])
3670
+ const low = parseTickSide(row['low_t'])
3671
+ if (
3672
+ bucket === undefined ||
3673
+ fillCount === undefined ||
3674
+ base === undefined ||
3675
+ quote === undefined ||
3676
+ !open ||
3677
+ !close ||
3678
+ !high ||
3679
+ !low
3680
+ )
3681
+ continue
3682
+ data.push({
3683
+ close: ohlcRate(close),
3684
+ fillCount,
3685
+ high: ohlcRate(high),
3686
+ id: `${pair.key}-${interval}-${bucket}`,
3687
+ low: ohlcRate(low),
3688
+ open: ohlcRate(open),
3689
+ timestamp: new Date(bucket * bucketMs).toISOString(),
3690
+ volume: { base, quote },
3691
+ })
3692
+ }
3693
+
3694
+ // The full window is always aggregated now; the field stays for contract
3695
+ // stability until the next breaking version.
3696
+ return { data, truncated: false }
3697
+ })
3698
+ }
3699
+
3700
+ declare namespace getPairOhlc {
3701
+ /** Options for the pair OHLC query. */
3702
+ type Options = {
3703
+ chainId: z.output<typeof Schema.ChainId>
3704
+ /** Bucket size. */
3705
+ interval: z.output<typeof schema.getPairOhlc.Interval>
3706
+ /** The resolved pair whose OHLC is being aggregated. */
3707
+ pair: getPairs.Row
3708
+ /** Rolling lookback. */
3709
+ window: z.output<typeof schema.getPairOhlc.Window>
3710
+ }
3711
+ }
3712
+
3713
+ /**
3714
+ * Hard cap on the per-pair `OrderPlaced` scan that backs a resting-order
3715
+ * snapshot. Bounds the worst-case SQL `LIMIT` and the number of `orderId`s
3716
+ * batched into the cancellation/fill join. When the cap is hit the snapshot
3717
+ * reflects only the most-recent window of placements; the response carries
3718
+ * `truncated: true` so callers know older orders may be missing.
3719
+ */
3720
+ const orderScanCap = 5000
3721
+
3722
+ /**
3723
+ * Scan cap for the global resting-order feed (all pairs). Lower than
3724
+ * {@link orderScanCap} on purpose: the global scan has no `token` predicate to
3725
+ * narrow it, so it both reads more `OrderPlaced` rows and fans out more
3726
+ * `OrderCancelled`/`OrderFilled` IN-batches than any per-pair scan. Each of
3727
+ * those queries is an independent transient-`db error` opportunity under CI
3728
+ * load, and a smaller window keeps the global request to a handful of round
3729
+ * trips. Deep pages still flip `truncated`.
3730
+ */
3731
+ const globalOrderScanCap = 2000
3732
+
3733
+ /**
3734
+ * Batch size for `OrderCancelled` / `OrderFilled` `WHERE "orderId" IN (...)`
3735
+ * lookups. ClickHouse evaluates the IN-list against the `topic1` bloom-filter
3736
+ * index after the TIDX pushdown rewrite re-encodes each literal (see
3737
+ * `tempoxyz/ak47` commit `273d88d`); very large lists still inflate SQL parse
3738
+ * cost, but each batch is also an independent transient-error round trip, so
3739
+ * 1000 balances parse cost against keeping the batch count (and thus the
3740
+ * failure surface) low — a typical pair snapshot still resolves in one trip and
3741
+ * the capped global feed in ~two.
3742
+ */
3743
+ const orderInBatchSize = 1000
3744
+
3745
+ /**
3746
+ * Signed-int16 bias used to encode `tick` in the cursor as a non-negative
3747
+ * uint string. `Cursor.Field === 'int'` is non-negative-only, so signed ticks
3748
+ * (range `[-32768, 32767]`) are shifted into `[0, 65535]` before encoding.
3749
+ */
3750
+ const orderCursorTickBias = 32768
3751
+
3752
+ /**
3753
+ * Resolves a page of resting maker orders, optionally scoped to one pair.
3754
+ *
3755
+ * Strategy: snapshot the resting set in one pass (memoized briefly to
3756
+ * amortize bursty UI polling), then sort, filter, and paginate the snapshot
3757
+ * in memory. The underlying scans target the indexer's ClickHouse OLAP path
3758
+ * (`engine=clickhouse`): `OrderPlaced` is filtered by the pair's base `token`
3759
+ * when scoped (string column with a bloom-filter index on `topic1`), and the
3760
+ * fill / cancellation joins use `WHERE "orderId" IN (...)` against the same
3761
+ * index. The decoded `tick` column has no ClickHouse index, so tick sort is
3762
+ * still applied app-side. Aggregation also runs in the app because
3763
+ * indexer-side `SUM(...)` over decoded columns is currently unreliable.
3764
+ *
3765
+ * `remaining = OrderPlaced.amount - sum(OrderFilled.amountFilled)`; orders
3766
+ * with an `OrderCancelled` event are excluded outright. Each row is annotated
3767
+ * with its pair: the scoped feed reuses the resolved `pair`, the unscoped
3768
+ * feed resolves per row from the supplied pair index (rows whose pair is
3769
+ * unknown are dropped).
3770
+ */
3771
+ async function getOrders(c: Context<App.Environment>, options: getOrders.Options) {
3772
+ const { chainId, cursor, limit, maker, order, page, pair, pairIndex, side, sort } = options
3773
+ const store = c.get('store')
3774
+
3775
+ return Timing.time(c, 'orders', async () => {
3776
+ // Snapshot is invariant in `(chainId, base?, maker?)`; sort/side/order are
3777
+ // applied app-side so they don't fragment the cache. Short TTL keeps the
3778
+ // resting set fresh against incoming fills/cancellations without making
3779
+ // each request pay the scan cost.
3780
+ const snapshot = await Store.memoize(
3781
+ () =>
3782
+ loadRestingOrders(
3783
+ c,
3784
+ pair
3785
+ ? { chainId, base: pair.base, maker }
3786
+ : { chainId, maker, scanCap: globalOrderScanCap },
3787
+ ),
3788
+ {
3789
+ key: `exchange:v1:${chainId}:orders:${pair ? pair.base : 'all'}:${maker ?? '*'}:v1`,
3790
+ store,
3791
+ ttl: Ttl.seconds(15),
3792
+ },
3793
+ )
3794
+
3795
+ const { hasMore, nextCursor, pageRows, total } = pageRestingOrders(snapshot.orders, {
3796
+ cursor,
3797
+ limit,
3798
+ order,
3799
+ page,
3800
+ side,
3801
+ sort,
3802
+ })
3803
+
3804
+ const data: z.output<typeof schema.getOrders.Order>[] = []
3805
+ for (const row of pageRows) {
3806
+ const rowPair = pair ?? pairIndex.get(row.token)
3807
+ if (!rowPair) continue
3808
+ const shaped = shapeRestingOrder(row)
3809
+ if (!shaped) continue
3810
+ data.push({
3811
+ ...shaped,
3812
+ pair: {
3813
+ base: { address: rowPair.base },
3814
+ key: rowPair.key,
3815
+ quote: { address: rowPair.quote },
3816
+ },
3817
+ })
3818
+ }
3819
+
3820
+ return { data, hasMore, nextCursor, total, truncated: snapshot.truncated }
3821
+ })
3822
+ }
3823
+
3824
+ declare namespace getOrders {
3825
+ /** Options for the resting-order page query. */
3826
+ type Options = {
3827
+ chainId: z.output<typeof Schema.ChainId>
3828
+ /** Opaque keyset cursor anchoring the page; omit for the head page. */
3829
+ cursor?: string | undefined
3830
+ limit: number
3831
+ /** Only include orders placed by this maker. */
3832
+ maker?: z.output<typeof Schema.Address> | undefined
3833
+ /** 1-indexed page number (positional pagination; exclusive with `cursor`). */
3834
+ page?: number | undefined
3835
+ /** Sort direction applied to whatever `sort` selects. */
3836
+ order: 'asc' | 'desc'
3837
+ /** When set, scope the snapshot to this resolved pair. */
3838
+ pair?: getPairs.Row | undefined
3839
+ /** Pair index (base address → pair) used to resolve unscoped rows. */
3840
+ pairIndex: Map<string, getPairs.Row>
3841
+ /** Restrict to one side of the book. */
3842
+ side?: 'bid' | 'ask' | undefined
3843
+ /** Sort key. `tick` ranks by price; `time` orders by placement (block, log). */
3844
+ sort: 'tick' | 'time'
3845
+ }
3846
+ }
3847
+
3848
+ /**
3849
+ * Applies the side filter, sort, and keyset pagination over an in-memory
3850
+ * resting-order snapshot, returning the page rows plus pagination state.
3851
+ * Shared by the per-pair ({@link getPairOrders}) and global ({@link getOrders})
3852
+ * feeds so the sort comparator and cursor walk never drift between them.
3853
+ *
3854
+ * Sort: `tick` ranks by price (canonical orderbook orientation when paired
3855
+ * with `order=asc` for asks / `order=desc` for bids), tie-breaking by newest
3856
+ * placement so callers can always derive a stable cursor. `time` orders by
3857
+ * placement `(block_num, log_idx)`. The cursor encodes the full sort key (not
3858
+ * just `(block, log)`) so pagination stays correct even if the snapshot
3859
+ * rotates between requests and the previous-page row is gone.
3860
+ */
3861
+ function pageRestingOrders(
3862
+ orders: readonly RestingOrder[],
3863
+ options: pageRestingOrders.Options,
3864
+ ): { hasMore: boolean; nextCursor: string | null; pageRows: RestingOrder[]; total: number } {
3865
+ const { cursor: cursorToken, limit, order, side, sort } = options
3866
+ // Bounded positional lane (exclusive with `cursor` at the schema): the
3867
+ // snapshot is in memory, so a positional page is an exact slice of
3868
+ // `(page - 1) * limit` rows.
3869
+ const page = options.page !== undefined && options.page > 1 ? options.page : undefined
3870
+ const offset = page !== undefined ? (page - 1) * limit : undefined
3871
+
3872
+ // Side filter is the cheapest in-memory cut, so apply it first.
3873
+ const sided = side === undefined ? orders : orders.filter((row) => row.isBid === (side === 'bid'))
3874
+
3875
+ const dir = order === 'asc' ? 1 : -1
3876
+ const sorted = [...sided].sort((a, b) => {
3877
+ if (sort === 'tick') {
3878
+ if (a.tick !== b.tick) return (a.tick - b.tick) * dir
3879
+ if (a.blockNumber !== b.blockNumber) return b.blockNumber - a.blockNumber
3880
+ return b.logIndex - a.logIndex
3881
+ }
3882
+ if (a.blockNumber !== b.blockNumber) return (a.blockNumber - b.blockNumber) * dir
3883
+ return (a.logIndex - b.logIndex) * dir
3884
+ })
3885
+
3886
+ const cursorFields: readonly Cursor.Field[] =
3887
+ sort === 'tick' ? ['uint', 'int', 'int'] : ['int', 'int']
3888
+ const cursor = cursorToken ? Cursor.decode(cursorToken, cursorFields) : undefined
3889
+
3890
+ let startIndex = offset ?? 0
3891
+ if (cursor) {
3892
+ while (startIndex < sorted.length && !isPastCursor(sorted[startIndex]!, cursor, sort, dir))
3893
+ startIndex += 1
3894
+ }
3895
+
3896
+ const window = sorted.slice(startIndex, startIndex + limit + 1)
3897
+ const hasMore = window.length > limit
3898
+ const pageRows = window.slice(0, limit)
3899
+ const last = hasMore ? pageRows.at(-1) : undefined
3900
+ const nextTuple = last ? cursorTupleFromOrder(last, sort) : undefined
3901
+ const nextCursor = nextTuple ? Cursor.encode(nextTuple) : null
3902
+
3903
+ // `total` is the count of side-filtered resting orders before pagination —
3904
+ // the in-memory basis for the opt-in `totalCount` (a lower bound when the
3905
+ // underlying placement scan was truncated).
3906
+ return { hasMore, nextCursor, pageRows, total: sided.length }
3907
+ }
3908
+
3909
+ declare namespace pageRestingOrders {
3910
+ /** Options for {@link pageRestingOrders}. */
3911
+ type Options = {
3912
+ /** Opaque keyset cursor anchoring the page; omit for the head page. */
3913
+ cursor?: string | undefined
3914
+ limit: number
3915
+ /** 1-indexed page number (positional pagination; exclusive with `cursor`). */
3916
+ page?: number | undefined
3917
+ /** Sort direction applied to whatever `sort` selects. */
3918
+ order: 'asc' | 'desc'
3919
+ /** Restrict to one side of the book. */
3920
+ side?: 'bid' | 'ask' | undefined
3921
+ /** Sort key. `tick` ranks by price; `time` orders by placement (block, log). */
3922
+ sort: 'tick' | 'time'
3923
+ }
3924
+ }
3925
+
3926
+ /**
3927
+ * Number of grid ticks probed per side per multicall round while walking the
3928
+ * book outward from its best tick. Real books cluster near peg, so the
3929
+ * default `levels` resolves in one round for a typical book; a sparse book
3930
+ * keeps walking in rounds of this size until the tick range is exhausted
3931
+ * (worst case ~401 positions per side, the old blanket-scan cost).
3932
+ */
3933
+ const depthScanChunk = 100
3934
+
3935
+ /**
3936
+ * Resolves the current orderbook depth for a single pair, denominated in the
3937
+ * base token. The stablecoin DEX precompile exposes per-tick aggregates via
3938
+ * `getTickLevel(base, tick, isBid)`. Rather than blanket-scanning every valid
3939
+ * tick (~401 per side — multiple seconds of serial EVM work per request), we
3940
+ * first read the book head (`books(pairKey)` → `bestBidTick`/`bestAskTick`)
3941
+ * and walk each side outward from its best tick in {@link depthScanChunk}
3942
+ * chunks, stopping as soon as `levels` non-empty levels are collected. Viem's
3943
+ * deployless multicall coalesces each chunk into one `eth_call`. The best
3944
+ * tick is by definition the side's outermost populated tick, so the outward
3945
+ * walk observes every non-empty level in best-first order. A side whose best
3946
+ * tick sits outside the valid range is empty (the precompile signals "no
3947
+ * orders" with the int16 min/max sentinels) and is returned without scanning.
3948
+ *
3949
+ * Memoized briefly via `Store.memoize` so concurrent dashboard requests for
3950
+ * the same pair share one RPC fanout; the route's `Cache.policies.feed`
3951
+ * response cache layers on top of that.
3952
+ */
3953
+ async function getPairDepth(c: Context<App.Environment>, options: getPairDepth.Options) {
3954
+ const { chainId, levels, pair } = options
3955
+ const client = c.get('getClient')(chainId)
3956
+ const store = c.get('store')
3957
+
3958
+ /**
3959
+ * Walks one side outward from its best tick, probing `depthScanChunk` grid
3960
+ * positions per round, until `levels` non-empty levels are collected or the
3961
+ * tick range is exhausted. Probed ticks stay in best-first order, so
3962
+ * `collectLevels` builds `cumulativeSize` and applies the cap directly.
3963
+ */
3964
+ const scanSide = async (isBid: boolean, bestTick: number) => {
3965
+ if (bestTick < Tick.minTick || bestTick > Tick.maxTick) return []
3966
+ const step = isBid ? -tickSpacing : tickSpacing
3967
+ const boundary = isBid ? Tick.minTick : Tick.maxTick
3968
+ const inRange = (tick: number) => (isBid ? tick >= boundary : tick <= boundary)
3969
+
3970
+ const probedTicks: number[] = []
3971
+ const probedLevels: { totalLiquidity: bigint }[] = []
3972
+ let nonEmpty = 0
3973
+ let cursor = bestTick
3974
+ while (nonEmpty < levels && inRange(cursor)) {
3975
+ const chunk: number[] = []
3976
+ while (chunk.length < depthScanChunk && inRange(cursor)) {
3977
+ chunk.push(cursor)
3978
+ cursor += step
3979
+ }
3980
+ // Each chunk's per-tick reads share one deployless multicall round trip
3981
+ // via viem's auto-batching. Only `totalLiquidity` is needed.
3982
+ const results = await Promise.all(
3983
+ chunk.map((tick) => client.dex.getTickLevel({ base: pair.base, isBid, tick })),
3984
+ )
3985
+ for (let i = 0; i < chunk.length; i++) {
3986
+ probedTicks.push(chunk[i]!)
3987
+ probedLevels.push(results[i]!)
3988
+ if (results[i]!.totalLiquidity > 0n) nonEmpty += 1
3989
+ }
3990
+ }
3991
+ return collectLevels(probedTicks, probedLevels, {
3992
+ limit: levels,
3993
+ pairKey: pair.key,
3994
+ side: isBid ? 'bid' : 'ask',
3995
+ })
3996
+ }
3997
+
3998
+ return Timing.time(c, 'pair_depth', () =>
3999
+ Store.memoize(
4000
+ async () => {
4001
+ const book = await Actions.dex.getOrderbook(client, {
4002
+ base: pair.base,
4003
+ quote: pair.quote,
4004
+ })
4005
+ const [asks, bids] = await Promise.all([
4006
+ scanSide(false, book.bestAskTick),
4007
+ scanSide(true, book.bestBidTick),
4008
+ ])
4009
+ return { asks, bids }
4010
+ },
4011
+ {
4012
+ key: `exchange:v1:${chainId}:depth:${pair.base}:${pair.quote}:levels:${levels}`,
4013
+ store,
4014
+ // The response cache (10s) already absorbs concurrent reads at the
4015
+ // edge; this in-process memo just dedupes overlapping in-flight
4016
+ // multicalls within a single instance.
4017
+ ttl: Ttl.seconds(5),
4018
+ },
4019
+ ),
4020
+ )
4021
+ }
4022
+
4023
+ declare namespace getPairDepth {
4024
+ type Options = {
4025
+ chainId: z.output<typeof Schema.ChainId>
4026
+ /** Maximum number of non-empty levels to keep per side, in best-first order. */
4027
+ levels: number
4028
+ pair: getPairs.Row
4029
+ }
4030
+ }
4031
+
4032
+ /**
4033
+ * Returns `true` when `row` falls strictly after the position the cursor was
4034
+ * issued at, under the active sort. Mirrors the comparator in `getPairOrders`
4035
+ * so the cursor walk and the sort agree on row order.
4036
+ */
4037
+ function isPastCursor(
4038
+ row: RestingOrder,
4039
+ cursor: Cursor.Cursor,
4040
+ sort: 'tick' | 'time',
4041
+ dir: 1 | -1,
4042
+ ): boolean {
4043
+ if (sort === 'tick') {
4044
+ const cursorTick = Number(cursor[0]) - orderCursorTickBias
4045
+ const cursorBlock = cursor[1] as number
4046
+ const cursorLog = cursor[2] as number
4047
+ if (row.tick !== cursorTick) return (row.tick - cursorTick) * dir > 0
4048
+ if (row.blockNumber !== cursorBlock) return row.blockNumber < cursorBlock
4049
+ return row.logIndex < cursorLog
4050
+ }
4051
+ const cursorBlock = cursor[0] as number
4052
+ const cursorLog = cursor[1] as number
4053
+ if (row.blockNumber !== cursorBlock) return (row.blockNumber - cursorBlock) * dir > 0
4054
+ return (row.logIndex - cursorLog) * dir > 0
4055
+ }
4056
+
4057
+ /** Builds the cursor tuple for the last row of a page, matching the active sort. */
4058
+ function cursorTupleFromOrder(row: RestingOrder, sort: 'tick' | 'time'): Cursor.Cursor {
4059
+ if (sort === 'tick')
4060
+ return [String(row.tick + orderCursorTickBias), row.blockNumber, row.logIndex]
4061
+ return [row.blockNumber, row.logIndex]
4062
+ }
4063
+
4064
+ /**
4065
+ * In-memory representation of a single resting order. Field types are already
4066
+ * narrowed/validated; the public response shape is built by `shapeRestingOrder`.
4067
+ *
4068
+ * `amount` and `remaining` are decimal-string integers (not `bigint`) so the
4069
+ * snapshot survives `JSON.stringify` inside `Store.memoize`. Bigint arithmetic
4070
+ * is kept inside `loadRestingOrders` where fills are subtracted from the raw
4071
+ * placement amount.
4072
+ */
4073
+ type RestingOrder = {
4074
+ amount: string
4075
+ blockNumber: number
4076
+ isBid: boolean
4077
+ logIndex: number
4078
+ maker: z.output<typeof Schema.Address>
4079
+ orderId: string
4080
+ remaining: string
4081
+ tick: number
4082
+ timestamp: string
4083
+ /** The order's base token, used by the global feed to resolve its pair. */
4084
+ token: z.output<typeof Schema.TokenAddress>
4085
+ transactionHash: z.output<typeof Schema.Hash>
4086
+ }
4087
+
4088
+ /**
4089
+ * Loads every resting maker order for a pair: scan `OrderPlaced` filtered by
4090
+ * the pair's base token (and optional `maker`), then subtract fills and drop
4091
+ * cancelled orders using batched `WHERE "orderId" IN (...)` joins.
4092
+ *
4093
+ * Returns the resting set along with a `truncated` flag set when the
4094
+ * `OrderPlaced` scan hit `orderScanCap`. Callers surface the flag so
4095
+ * pagination consumers can detect a partial view of the book.
4096
+ */
4097
+ async function loadRestingOrders(
4098
+ c: Context<App.Environment>,
4099
+ options: {
4100
+ /** Restrict to one pair's base token; omit for the global feed (all pairs). */
4101
+ base?: z.output<typeof Schema.TokenAddress> | undefined
4102
+ chainId: z.output<typeof Schema.ChainId>
4103
+ maker?: z.output<typeof Schema.Address> | undefined
4104
+ /** Newest-placement scan window; defaults to {@link orderScanCap}. */
4105
+ scanCap?: number | undefined
4106
+ },
4107
+ ): Promise<{ orders: RestingOrder[]; truncated: boolean }> {
4108
+ const { base, chainId, maker, scanCap = orderScanCap } = options
4109
+ const tidx = c.get('getTidx')(chainId)
4110
+
4111
+ // Scan window is intentionally newest-first so a `truncated` snapshot still
4112
+ // reflects current market activity rather than ancient placements.
4113
+ const filters = [`address = '${stablecoinDex}'`]
4114
+ if (base !== undefined) filters.push(`token = '${base}'`)
4115
+ if (maker !== undefined) filters.push(`maker = '${maker}'`)
4116
+ const where = `WHERE ${filters.join(' AND ')}`
4117
+
4118
+ // ClickHouse OLAP path: bloom-filter index on `topic1` (and the `token`
4119
+ // string column) handles `WHERE token = '<base>'` and the downstream
4120
+ // `"orderId" IN (...)` joins natively, without the per-row decode cost of
4121
+ // the Postgres `abi_uint(topic1)` fallback.
4122
+ //
4123
+ // `"isBid"` is aliased to a name that is *not* in the OrderPlaced event
4124
+ // signature so tidx.ts's signature-driven result decoder skips it. ClickHouse
4125
+ // serializes booleans as `1`/`0`, but tidx.ts validates `bool` columns with a
4126
+ // strict `z.boolean()` derived from the signature and rejects integer
4127
+ // representations. Aliasing dodges that decoder and lets us coerce
4128
+ // numerically in `parsePlacedRow`.
4129
+ const result = await tidx.fetch({
4130
+ chainId,
4131
+ engine: 'clickhouse',
4132
+ query: `
4133
+ SELECT "orderId", maker, amount, token, "isBid" AS is_bid_int, tick, tx_hash, block_num, log_idx, block_timestamp
4134
+ FROM dex_orders
4135
+ ${where}
4136
+ ORDER BY block_num DESC, log_idx DESC
4137
+ LIMIT ${scanCap + 1}
4138
+ ` as string,
4139
+ })
4140
+
4141
+ const truncated = result.rows.length > scanCap
4142
+ const placedRows = result.rows.slice(0, scanCap)
4143
+ const placed: RestingOrder[] = []
4144
+ for (const row of placedRows) {
4145
+ const parsed = parsePlacedRow(row)
4146
+ if (parsed) placed.push(parsed)
4147
+ }
4148
+ if (placed.length === 0) return { orders: [], truncated }
4149
+
4150
+ const orderIds = placed.map((row) => row.orderId)
4151
+ const [cancelled, fillSums] = await Promise.all([
4152
+ fetchCancelledByIds(c, { chainId, orderIds }),
4153
+ fetchFillSumsByIds(c, { chainId, orderIds }),
4154
+ ])
4155
+
4156
+ const orders: RestingOrder[] = []
4157
+ for (const row of placed) {
4158
+ if (cancelled.has(row.orderId)) continue
4159
+ const filled = fillSums.get(row.orderId) ?? 0n
4160
+ const remaining = BigInt(row.amount) - filled
4161
+ if (remaining <= 0n) continue
4162
+ orders.push({ ...row, remaining: remaining.toString() })
4163
+ }
4164
+
4165
+ return { orders, truncated }
4166
+ }
4167
+
4168
+ /**
4169
+ * Base-token chunk size for the multi-book `WHERE token IN (...)` scan in
4170
+ * {@link resolveBookReserves}. `tidx.fetch` transmits queries as GET, so a
4171
+ * single `IN (...)` of every base in a wide page overflows the request URI;
4172
+ * 200 lowercase 20-byte literals stay comfortably under the indexer's ~60 KB
4173
+ * SQL ceiling. Chunks resolve serially and merge.
4174
+ */
4175
+ const reserveBaseInBatchSize = 200
4176
+
4177
+ /**
4178
+ * Computes per-book resting-order reserves for a set of base tokens, mirroring
4179
+ * the legacy `/gecko` adapter's pool-liquidity semantics: scan `OrderPlaced`
4180
+ * scoped to the given bases, subtract fills, drop cancelled orders, then
4181
+ * aggregate each order's `remaining` by side — asks into the base reserve, bids
4182
+ * into the quote reserve.
4183
+ *
4184
+ * The bid leg is added in its placed (base-denominated) units **without** tick
4185
+ * conversion, matching the legacy adapter exactly so GeckoTerminal's per-pool
4186
+ * `reserve_in_usd` stays consistent with the figures it has indexed historically.
4187
+ * (The on-chain `balanceOf` of the DEX settlement account — what the adapter
4188
+ * used previously — is a single shared pool across every book sharing a quote
4189
+ * token, so it cannot express per-book liquidity.)
4190
+ *
4191
+ * Reserves are keyed by **base** token (a book's identity), so two books sharing
4192
+ * a quote token report distinct reserves. The `OrderPlaced` scan uses a fair
4193
+ * per-token cap (`LIMIT ... BY token`) so one active book can't starve the
4194
+ * others' newest placements under a shared budget; `truncated` is set when any
4195
+ * book hits the cap. Like {@link loadRestingOrders}, this reads only
4196
+ * `OrderPlaced` (`dex_orders`), so flip orders (which mutate side/tick via
4197
+ * `OrderFlipped` without a new placement) are not reflected — acceptable for
4198
+ * legacy parity, but not an exact economic TVL.
4199
+ */
4200
+ export async function resolveBookReserves(
4201
+ c: Context<App.Environment>,
4202
+ options: resolveBookReserves.Options,
4203
+ ): Promise<resolveBookReserves.Result> {
4204
+ const { bases, chainId, scanCap = orderScanCap } = options
4205
+ const reserves = new Map<string, resolveBookReserves.Reserve>()
4206
+ const unique = [...new Set(bases.map((base) => base.toLowerCase()))]
4207
+ if (unique.length === 0) return { reserves, truncated: false }
4208
+
4209
+ const tidx = c.get('getTidx')(chainId)
4210
+
4211
+ // Scan the newest `scanCap` `OrderPlaced` rows per base. `LIMIT ... BY token`
4212
+ // is the ClickHouse fair cap; the app-side `perToken` guard re-applies it
4213
+ // across chunks (a base can't span chunks, but the guard also flips
4214
+ // `truncated` consistently) and against the `scanCap + 1` probe row.
4215
+ let truncated = false
4216
+ const placed: RestingOrder[] = []
4217
+ const perToken = new Map<string, number>()
4218
+ for (let i = 0; i < unique.length; i += reserveBaseInBatchSize) {
4219
+ const group = unique.slice(i, i + reserveBaseInBatchSize)
4220
+ const list = group.map((base) => `'${base}'`).join(', ')
4221
+ const result = await tidx.fetch({
4222
+ chainId,
4223
+ engine: 'clickhouse',
4224
+ query: `
4225
+ SELECT "orderId", maker, amount, token, "isBid" AS is_bid_int, tick, tx_hash, block_num, log_idx, block_timestamp
4226
+ FROM dex_orders
4227
+ WHERE address = '${stablecoinDex}' AND token IN (${list})
4228
+ ORDER BY block_num DESC, log_idx DESC
4229
+ LIMIT ${scanCap + 1} BY token
4230
+ ` as string,
4231
+ })
4232
+ for (const row of result.rows) {
4233
+ const parsed = parsePlacedRow(row)
4234
+ if (!parsed) continue
4235
+ const count = perToken.get(parsed.token) ?? 0
4236
+ if (count >= scanCap) {
4237
+ truncated = true
4238
+ continue
4239
+ }
4240
+ perToken.set(parsed.token, count + 1)
4241
+ placed.push(parsed)
4242
+ }
4243
+ }
4244
+ if (placed.length === 0) return { reserves, truncated }
4245
+
4246
+ const orderIds = placed.map((row) => row.orderId)
4247
+ const [cancelled, fillSums] = await Promise.all([
4248
+ fetchCancelledByIds(c, { chainId, orderIds }),
4249
+ fetchFillSumsByIds(c, { chainId, orderIds }),
4250
+ ])
4251
+
4252
+ for (const row of placed) {
4253
+ if (cancelled.has(row.orderId)) continue
4254
+ const filled = fillSums.get(row.orderId) ?? 0n
4255
+ const remaining = BigInt(row.amount) - filled
4256
+ if (remaining <= 0n) continue
4257
+ const reserve = reserves.get(row.token) ?? { base: 0n, quote: 0n }
4258
+ if (row.isBid) reserve.quote += remaining
4259
+ else reserve.base += remaining
4260
+ reserves.set(row.token, reserve)
4261
+ }
4262
+
4263
+ return { reserves, truncated }
4264
+ }
4265
+
4266
+ export declare namespace resolveBookReserves {
4267
+ type Options = {
4268
+ /** Book base tokens to compute reserves for (case-insensitive). */
4269
+ bases: readonly string[]
4270
+ chainId: z.output<typeof Schema.ChainId>
4271
+ /** Newest-placement scan window per base; defaults to {@link orderScanCap}. */
4272
+ scanCap?: number | undefined
4273
+ }
4274
+
4275
+ /** Per-book resting liquidity, in each token's smallest unit. */
4276
+ type Reserve = {
4277
+ /** Ask-side resting liquidity (base token units). */
4278
+ base: bigint
4279
+ /** Bid-side resting liquidity (base-denominated, legacy-compatible). */
4280
+ quote: bigint
4281
+ }
4282
+
4283
+ type Result = {
4284
+ /** Reserves keyed by lowercase base token. */
4285
+ reserves: Map<string, Reserve>
4286
+ /** Set when any book hit `scanCap` (older placements may be missing). */
4287
+ truncated: boolean
4288
+ }
4289
+ }
4290
+
4291
+ /**
4292
+ * Parses one `OrderPlaced` row into the in-memory `RestingOrder` shape, with
4293
+ * `remaining` provisionally set to `amount` (the loader fills it in once fills
4294
+ * are joined). Returns `undefined` for malformed rows so the caller can drop
4295
+ * them without failing the whole snapshot.
4296
+ */
4297
+ function parsePlacedRow(row: Record<string, unknown>): RestingOrder | undefined {
4298
+ const orderId = Value.toIntegerString(row['orderId'])
4299
+ const maker = Schema.Address.safeParse(row['maker'])
4300
+ const amount = Value.toIntegerString(row['amount'])
4301
+ const tick = Value.toNumber(row['tick'])
4302
+ const transactionHash = Schema.Hash.safeParse(row['tx_hash'])
4303
+ const blockNumber = Value.toNumber(row['block_num'])
4304
+ const logIndex = Value.toNumber(row['log_idx'])
4305
+ const timestamp = Value.toIsoDateTime(row['block_timestamp'])
4306
+ const token = Schema.TokenAddress.safeParse(row['token'])
4307
+ if (
4308
+ orderId === undefined ||
4309
+ !maker.success ||
4310
+ amount === undefined ||
4311
+ tick === undefined ||
4312
+ !transactionHash.success ||
4313
+ blockNumber === undefined ||
4314
+ logIndex === undefined ||
4315
+ timestamp === undefined ||
4316
+ !token.success
4317
+ )
4318
+ return undefined
4319
+ // `is_bid_int` is the aliased `OrderPlaced."isBid"` column; ClickHouse
4320
+ // serializes booleans as `1`/`0`, and Postgres returns native booleans, so
4321
+ // coerce both representations.
4322
+ const isBidRaw = row['is_bid_int']
4323
+ const isBid = isBidRaw === true || isBidRaw === 1 || isBidRaw === '1'
4324
+ // `amount` is already a decimal string (from `Value.toIntegerString`);
4325
+ // `remaining` is provisionally the same until `loadRestingOrders` subtracts
4326
+ // fills against it.
4327
+ return {
4328
+ amount,
4329
+ blockNumber,
4330
+ isBid,
4331
+ logIndex,
4332
+ maker: maker.data,
4333
+ orderId,
4334
+ remaining: amount,
4335
+ tick,
4336
+ timestamp,
4337
+ token: token.data,
4338
+ transactionHash: transactionHash.data,
4339
+ }
4340
+ }
4341
+
4342
+ /**
4343
+ * Resolves the set of cancelled `orderId`s in the snapshot window via batched
4344
+ * `OrderCancelled WHERE "orderId" IN (...)` queries. Empty input short-circuits
4345
+ * without touching the indexer.
4346
+ */
4347
+ async function fetchCancelledByIds(
4348
+ c: Context<App.Environment>,
4349
+ options: { chainId: z.output<typeof Schema.ChainId>; orderIds: readonly string[] },
4350
+ ): Promise<Set<string>> {
4351
+ const { chainId, orderIds } = options
4352
+ const cancelled = new Set<string>()
4353
+ if (orderIds.length === 0) return cancelled
4354
+ const tidx = c.get('getTidx')(chainId)
4355
+ for (let i = 0; i < orderIds.length; i += orderInBatchSize) {
4356
+ const batch = orderIds.slice(i, i + orderInBatchSize)
4357
+ const result = await tidx.fetch({
4358
+ chainId,
4359
+ engine: 'clickhouse',
4360
+ query: `
4361
+ SELECT "orderId"
4362
+ FROM OrderCancelled
4363
+ WHERE "orderId" IN (${batch.join(', ')})
4364
+ `,
4365
+ signatures: [orderCancelledSignature],
4366
+ })
4367
+ for (const row of result.rows) {
4368
+ const id = Value.toIntegerString(row['orderId'])
4369
+ if (id !== undefined) cancelled.add(id)
4370
+ }
4371
+ }
4372
+ return cancelled
4373
+ }
4374
+
4375
+ /**
4376
+ * Sums `amountFilled` per `orderId` across all matching `OrderFilled` rows in
4377
+ * the snapshot window via batched `WHERE "orderId" IN (...)` queries. Each
4378
+ * placement may have many partial fills, so the loader sums them in the app
4379
+ * (indexer-side `SUM(...)` over decoded columns is currently unreliable).
4380
+ */
4381
+ async function fetchFillSumsByIds(
4382
+ c: Context<App.Environment>,
4383
+ options: { chainId: z.output<typeof Schema.ChainId>; orderIds: readonly string[] },
4384
+ ): Promise<Map<string, bigint>> {
4385
+ const { chainId, orderIds } = options
4386
+ const sums = new Map<string, bigint>()
4387
+ if (orderIds.length === 0) return sums
4388
+ const tidx = c.get('getTidx')(chainId)
4389
+ for (let i = 0; i < orderIds.length; i += orderInBatchSize) {
4390
+ const batch = orderIds.slice(i, i + orderInBatchSize)
4391
+ const result = await tidx.fetch({
4392
+ chainId,
4393
+ engine: 'clickhouse',
4394
+ query: `
4395
+ SELECT "orderId", "amountFilled"
4396
+ FROM dex_fills
4397
+ WHERE "orderId" IN (${batch.join(', ')})
4398
+ ` as string,
4399
+ })
4400
+ for (const row of result.rows) {
4401
+ const id = Value.toIntegerString(row['orderId'])
4402
+ const filled = Value.toIntegerString(row['amountFilled'])
4403
+ if (id === undefined || filled === undefined) continue
4404
+ sums.set(id, (sums.get(id) ?? 0n) + BigInt(filled))
4405
+ }
4406
+ }
4407
+ return sums
4408
+ }
4409
+
4410
+ /**
4411
+ * Validates and shapes one resting order into the public response shape. Rate
4412
+ * is destination/source ratio relative to a taker: `price` for bids,
4413
+ * `1/price` for asks, formatted to the same 5-dp precision as `Tick.toPrice`
4414
+ * uses for `Tick.priceScale`.
4415
+ */
4416
+ function shapeRestingOrder(
4417
+ row: RestingOrder,
4418
+ ): z.output<typeof schema.getOrders.RestingOrder> | undefined {
4419
+ const priceScale = BigInt(Tick.priceScale)
4420
+ const tickOffset = BigInt(row.tick)
4421
+ const price = Tick.toPrice(row.tick)
4422
+ const rate = row.isBid
4423
+ ? price
4424
+ : (
4425
+ Number(priceScale * priceScale) /
4426
+ Number(priceScale + tickOffset) /
4427
+ Number(priceScale)
4428
+ ).toFixed(5)
4429
+
4430
+ return {
4431
+ amount: row.amount,
4432
+ blockNumber: row.blockNumber,
4433
+ id: row.orderId,
4434
+ logIndex: row.logIndex,
4435
+ maker: row.maker,
4436
+ orderId: row.orderId,
4437
+ placedAt: row.timestamp,
4438
+ price,
4439
+ rate,
4440
+ remaining: row.remaining,
4441
+ side: row.isBid ? 'bid' : 'ask',
4442
+ tick: row.tick,
4443
+ transactionHash: row.transactionHash,
4444
+ }
4445
+ }
4446
+
4447
+ /**
4448
+ * Builds the per-side levels from a best-first list of `getTickLevel` results.
4449
+ * Filters out empty ticks, accumulates `size` into `cumulativeSize` as we walk
4450
+ * outward, and truncates to `limit` non-empty levels.
4451
+ */
4452
+ function collectLevels(
4453
+ ticks: readonly number[],
4454
+ results: readonly { totalLiquidity: bigint }[],
4455
+ options: collectLevels.Options,
4456
+ ): z.output<typeof schema.getPairDepth.Level>[] {
4457
+ const { limit, pairKey, side } = options
4458
+ const out: z.output<typeof schema.getPairDepth.Level>[] = []
4459
+ let running = 0n
4460
+ for (let i = 0; i < ticks.length && out.length < limit; i++) {
4461
+ const size = results[i]?.totalLiquidity
4462
+ if (size === undefined || size === 0n) continue
4463
+ running += size
4464
+ out.push({
4465
+ cumulativeSize: running.toString(),
4466
+ id: `${pairKey}-${side}-${ticks[i]!}`,
4467
+ price: Tick.toPrice(ticks[i]!),
4468
+ size: size.toString(),
4469
+ tick: ticks[i]!,
4470
+ })
4471
+ }
4472
+ return out
4473
+ }
4474
+
4475
+ declare namespace collectLevels {
4476
+ type Options = {
4477
+ limit: number
4478
+ pairKey: z.output<typeof Schema.Hash>
4479
+ side: 'ask' | 'bid'
4480
+ }
4481
+ }
4482
+
4483
+ /**
4484
+ * Resolves a single DEX order's live placement + current state via the
4485
+ * on-chain `dex.getOrder` view. The contract returns `orderId === 0n` when
4486
+ * an order is unknown, which the caller surfaces as a 404. The pair the
4487
+ * order belongs to is resolved from `bookKey` against the cached pair index.
4488
+ *
4489
+ * Memoized briefly so a burst of `/orders/:orderId` requests for the same id
4490
+ * coalesces into a single RPC; the TTL stays short because `remaining`
4491
+ * decreases as fills land.
4492
+ */
4493
+ async function getOrder(c: Context<App.Environment>, options: getOrder.Options) {
4494
+ const { chainId, orderId } = options
4495
+ const store = c.get('store')
4496
+ const client = c.get('getClient')(chainId)
4497
+
4498
+ return Timing.time(c, 'order', () =>
4499
+ Store.memoize(
4500
+ async () => {
4501
+ // The on-chain DEX exposes the full order struct (placement params
4502
+ // plus current `remaining`) in one view call. We resolve the pair
4503
+ // off the returned `bookKey` concurrently with the RPC; on a warm
4504
+ // pair-index cache that's effectively free.
4505
+ const [chainOrder, pairsByKey] = await Promise.all([
4506
+ Timing.time(c, 'order_dex_get', () =>
4507
+ // The DEX reverts with `OrderDoesNotExist()` for ids it no longer
4508
+ // holds (never placed, or filled/cancelled and cleared), rather
4509
+ // than returning the zero struct. Map that revert to not-found so
4510
+ // the route answers 404 instead of bubbling up as a 502.
4511
+ client.dex.getOrder({ orderId: BigInt(orderId) }).catch((cause) => {
4512
+ if (isOrderDoesNotExist(cause)) return undefined
4513
+ throw cause
4514
+ }),
4515
+ ),
4516
+ getPairIndexByKey(c, { chainId }),
4517
+ ])
4518
+
4519
+ // The DEX returns the zero struct (or reverts, handled above) when an
4520
+ // order id is unknown.
4521
+ if (!chainOrder || chainOrder.orderId === 0n) return undefined
4522
+
4523
+ // Pair index miss is a soft consistency floor (the index is bounded
4524
+ // by `pairIndexLimit`); without `(base, quote)` we cannot frame the
4525
+ // order, so surface as not-found.
4526
+ const pair = pairsByKey.get(chainOrder.bookKey.toLowerCase())
4527
+ if (!pair) return undefined
4528
+
4529
+ const tick = chainOrder.tick
4530
+ const isBid = chainOrder.isBid
4531
+ const tickOffset = BigInt(tick)
4532
+ const priceScaleBig = BigInt(Tick.priceScale)
4533
+ const price = Tick.toPrice(tick)
4534
+ const mode = isBid ? 'exactSource' : 'exactDestination'
4535
+ // Destination/source rate. Maker bids: taker sells base, receives
4536
+ // quote; rate is `price` (quote per base). Maker asks: taker buys
4537
+ // base; rate is `1/price`, formatted to the same fixed-decimal
4538
+ // precision the DEX price scale supplies.
4539
+ const rate = isBid
4540
+ ? price
4541
+ : (
4542
+ Number(priceScaleBig * priceScaleBig) /
4543
+ Number(priceScaleBig + tickOffset) /
4544
+ Number(priceScaleBig)
4545
+ ).toFixed(5)
4546
+
4547
+ return {
4548
+ amount: chainOrder.amount.toString(),
4549
+ flipTick: chainOrder.flipTick,
4550
+ id: chainOrder.orderId.toString(),
4551
+ isBid,
4552
+ isFlipOrder: chainOrder.isFlip,
4553
+ maker: chainOrder.maker.toLowerCase() as z.output<typeof Schema.Address>,
4554
+ mode,
4555
+ orderId: chainOrder.orderId.toString(),
4556
+ pair: {
4557
+ base: { address: pair.base },
4558
+ key: pair.key,
4559
+ quote: { address: pair.quote },
4560
+ },
4561
+ price,
4562
+ rate,
4563
+ remaining: chainOrder.remaining.toString(),
4564
+ tick,
4565
+ }
4566
+ },
4567
+ {
4568
+ key: `exchange:v1:${chainId}:order:${orderId}`,
4569
+ store,
4570
+ ttl: Ttl.seconds(15),
4571
+ },
4572
+ ),
4573
+ )
4574
+ }
4575
+
4576
+ declare namespace getOrder {
4577
+ type Options = {
4578
+ chainId: z.output<typeof Schema.ChainId>
4579
+ orderId: string
4580
+ }
4581
+ }
4582
+
4583
+ /**
4584
+ * Detects the DEX `OrderDoesNotExist()` custom-error revert thrown by
4585
+ * `dex.getOrder` for an unknown order id. Walks viem's wrapped error chain to
4586
+ * the decoded `ContractFunctionRevertedError` and matches its `errorName`, so a
4587
+ * different revert (or a transport failure) still propagates as a 502.
4588
+ */
4589
+ function isOrderDoesNotExist(error: unknown): boolean {
4590
+ if (!(error instanceof BaseError)) return false
4591
+ const revert = error.walk((e) => e instanceof ContractFunctionRevertedError)
4592
+ return (
4593
+ revert instanceof ContractFunctionRevertedError &&
4594
+ revert.data?.errorName === 'OrderDoesNotExist'
4595
+ )
4596
+ }
4597
+
4598
+ /**
4599
+ * Resolves a single page of `OrderFilled` events for one order. Pagination is
4600
+ * keyset on `(block_num, log_idx)`, which is unique and append-only for the
4601
+ * stream of fills against any one order — so cursors stay stable across new
4602
+ * fills landing.
4603
+ */
4604
+ async function getOrderFills(c: Context<App.Environment>, options: getOrderFills.Options) {
4605
+ const { chainId, cursor: cursorToken, limit, order, orderId } = options
4606
+ const store = c.get('store')
4607
+ const tidx = c.get('getTidx')(chainId)
4608
+ const direction = order === 'asc' ? 'ASC' : 'DESC'
4609
+ const cursor = cursorToken ? Cursor.decode(cursorToken, ['int', 'int']) : undefined
4610
+ // Bounded positional lane (exclusive with `cursor` at the schema): page 1 is
4611
+ // the head page, so it shares the head's SQL and cache entries; deeper pages
4612
+ // translate to a SQL OFFSET of `(page - 1) * limit` rows.
4613
+ const offset =
4614
+ options.page !== undefined && options.page > 1 ? (options.page - 1) * limit : undefined
4615
+
4616
+ return Timing.time(c, 'order_fills', () =>
4617
+ Store.memoize(
4618
+ async () => {
4619
+ const filters: string[] = [`address = '${stablecoinDex}'`, `"orderId" = ${orderId}`]
4620
+ if (cursor !== undefined)
4621
+ filters.push(
4622
+ Cursor.keyset([
4623
+ { literal: Cursor.literal(cursor[0]!, 'int'), name: 'block_num', order },
4624
+ { literal: Cursor.literal(cursor[1]!, 'int'), name: 'log_idx', order },
4625
+ ]),
4626
+ )
4627
+ const where = `WHERE ${filters.join(' AND ')}`
4628
+
4629
+ const result = await tidx.fetch({
4630
+ chainId,
4631
+ engine: 'clickhouse',
4632
+ query: `
4633
+ SELECT "orderId", taker, "amountFilled", "partialFill", tx_hash, block_num, log_idx, block_timestamp
4634
+ FROM dex_fills
4635
+ ${where}
4636
+ ORDER BY block_num ${direction}, log_idx ${direction}
4637
+ LIMIT ${limit + 1}${offset !== undefined ? ` OFFSET ${offset}` : ''}
4638
+ ` as string,
4639
+ })
4640
+
4641
+ const page = Cursor.paginate({
4642
+ rows: result.rows,
4643
+ limit,
4644
+ key: (row) => {
4645
+ const block = Value.toNumber(row['block_num'])
4646
+ const index = Value.toNumber(row['log_idx'])
4647
+ return block !== undefined && index !== undefined ? [block, index] : undefined
4648
+ },
4649
+ })
4650
+
4651
+ const data: z.output<typeof schema.getOrderFills.Fill>[] = []
4652
+ for (const row of page.rows) {
4653
+ const fill = parseOrderFillRow(row)
4654
+ if (fill) data.push(fill)
4655
+ }
4656
+
4657
+ return { data, nextCursor: page.nextCursor }
4658
+ },
4659
+ {
4660
+ key: `exchange:v1:${chainId}:order:${orderId}:fills:${order}:${cursor ? `cursor:${cursor[0]}:${cursor[1]}` : offset !== undefined ? `page:${options.page}` : 'head'}:${limit}`,
4661
+ store,
4662
+ ttl: Ttl.seconds(10),
4663
+ },
4664
+ ),
4665
+ )
4666
+ }
4667
+
4668
+ declare namespace getOrderFills {
4669
+ type Options = {
4670
+ chainId: z.output<typeof Schema.ChainId>
4671
+ cursor?: string | undefined
4672
+ limit: number
4673
+ /** 1-indexed page number (positional pagination; exclusive with `cursor`). */
4674
+ page?: number | undefined
4675
+ order: 'asc' | 'desc'
4676
+ orderId: string
4677
+ }
4678
+ }
4679
+
4680
+ /**
4681
+ * Exact count of an order's fills, sharing {@link getOrderFills}'s
4682
+ * `address`/`orderId` filter (minus pagination) so the count matches the page
4683
+ * it annotates. Fills are 1:1 with `OrderFilled` events for the order; the
4684
+ * `address`/`"orderId"` predicate aligns with the `dex_fills` sort key, so
4685
+ * ClickHouse counts via the sparse primary index and the result is exact and
4686
+ * cheap with no cap (`totalCountCapped` is always `false`). Feeds the opt-in
4687
+ * `meta.totalCount`.
4688
+ */
4689
+ async function countOrderFills(
4690
+ c: Context<App.Environment>,
4691
+ options: countOrderFills.Options,
4692
+ ): Promise<{ totalCountCapped: boolean; totalCount: number }> {
4693
+ const { chainId, orderId } = options
4694
+ const store = c.get('store')
4695
+ const tidx = c.get('getTidx')(chainId)
4696
+
4697
+ return Timing.time(c, 'order_fills_count', () =>
4698
+ Store.memoize(
4699
+ async () => {
4700
+ const result = await tidx.fetch({
4701
+ chainId,
4702
+ engine: 'clickhouse',
4703
+ query:
4704
+ `SELECT count(*) AS total FROM dex_fills WHERE address = '${stablecoinDex}' AND "orderId" = ${orderId}` as string,
4705
+ })
4706
+ const totalCount = Value.toNumber(result.rows[0]?.['total']) ?? 0
4707
+ return { totalCountCapped: false, totalCount }
4708
+ },
4709
+ {
4710
+ key: `exchange:v1:${chainId}:order:${orderId}:fills:count`,
4711
+ store,
4712
+ ttl: Ttl.seconds(10),
4713
+ },
4714
+ ),
4715
+ )
4716
+ }
4717
+
4718
+ declare namespace countOrderFills {
4719
+ type Options = {
4720
+ chainId: z.output<typeof Schema.ChainId>
4721
+ orderId: string
4722
+ }
4723
+ }
4724
+
4725
+ /**
4726
+ * Per-key pair index derived from the existing per-base index. Built once
4727
+ * and memoized at the same cadence as the pair index itself so chained
4728
+ * `getOrder` lookups stay an O(1) `Map.get`.
4729
+ */
4730
+ async function getPairIndexByKey(c: Context<App.Environment>, options: getPairIndex.Options) {
4731
+ const pairsByBase = await getPairIndex(c, options)
4732
+ const pairsByKey = new Map<string, getPairs.Row>()
4733
+ for (const pair of pairsByBase.values()) pairsByKey.set(pair.key.toLowerCase(), pair)
4734
+ return pairsByKey
4735
+ }
4736
+
4737
+ /**
4738
+ * Validates and shapes one `OrderFilled` row into the public `Fill` shape.
4739
+ * Drops the row when any required column is malformed so the caller can omit
4740
+ * it from the page without failing the whole response.
4741
+ */
4742
+ function parseOrderFillRow(
4743
+ row: Record<string, unknown>,
4744
+ ): z.output<typeof schema.getOrderFills.Fill> | undefined {
4745
+ const amountFilled = Value.toIntegerString(row['amountFilled'])
4746
+ const orderId = Value.toIntegerString(row['orderId'])
4747
+ const taker = Schema.Address.safeParse(row['taker'])
4748
+ const transactionHash = Schema.Hash.safeParse(row['tx_hash'])
4749
+ const blockNumber = Value.toNumber(row['block_num'])
4750
+ const logIndex = Value.toNumber(row['log_idx'])
4751
+ const timestamp = Value.toIsoDateTime(row['block_timestamp'])
4752
+ if (
4753
+ amountFilled === undefined ||
4754
+ orderId === undefined ||
4755
+ !taker.success ||
4756
+ !transactionHash.success ||
4757
+ blockNumber === undefined ||
4758
+ logIndex === undefined ||
4759
+ timestamp === undefined
4760
+ )
4761
+ return undefined
4762
+ return {
4763
+ amountFilled,
4764
+ blockNumber,
4765
+ filledAt: timestamp,
4766
+ id: `${transactionHash.data}-${logIndex}`,
4767
+ logIndex,
4768
+ orderId,
4769
+ partialFill: row['partialFill'] === true,
4770
+ taker: taker.data,
4771
+ transactionHash: transactionHash.data,
4772
+ }
4773
+ }