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