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,2292 @@
1
+ import { Hono, type Context } from 'hono'
2
+ import { AbiEvent, AbiParameters } from 'ox'
3
+ import { type Address, ContractFunctionExecutionError } from 'viem'
4
+ import * as z from 'zod/mini'
5
+
6
+ import type * as App from '../App.js'
7
+ import * as Auth from '../internal/Auth.js'
8
+ import * as Cache from '../internal/Cache.js'
9
+ import * as Cursor from '../internal/Cursor.js'
10
+ import * as OpenApi from '../internal/OpenApi.js'
11
+ import * as Path from '../internal/Path.js'
12
+ import * as Response from '../internal/Response.js'
13
+ import * as Schema from '../internal/Schema.js'
14
+ import * as Store from '../internal/Store.js'
15
+ import * as Tidx from '../internal/Tidx.js'
16
+ import * as Timing from '../internal/Timing.js'
17
+ import * as Ttl from '../internal/Ttl.js'
18
+ import * as Value from '../internal/Value.js'
19
+ import * as VerifiedTokens from '../internal/VerifiedTokens.js'
20
+ import * as Transactions from './transactions.js'
21
+
22
+ const tokenCreatedSignature =
23
+ 'event TokenCreated(address indexed token, string name, string symbol, string currency, address quoteToken, address admin, bytes32 salt)'
24
+
25
+ // Topic0 of the `TokenCreated` event
26
+ // (= 0x44f7b8011db3e3647a530b4ff635726de5fafc8fa8ad10f0f31c0eb9dd52fc65). The
27
+ // deployed indexer's `tokencreated` event CTE cannot serve the `quoteToken` /
28
+ // `admin` columns (selecting them returns `db error`), so the created-extras
29
+ // lookup reads the raw `logs` table by topic0 and ABI-decodes the data blob
30
+ // instead.
31
+ const tokenCreatedTopic = AbiEvent.getSelector(tokenCreatedSignature)
32
+
33
+ // Non-indexed `TokenCreated` data layout, in event-parameter order:
34
+ // `[name, symbol, currency, quoteToken, admin, salt]`.
35
+ const tokenCreatedData = AbiParameters.from([
36
+ 'string',
37
+ 'string',
38
+ 'string',
39
+ 'address',
40
+ 'address',
41
+ 'bytes32',
42
+ ])
43
+
44
+ // Topic0 of `Transfer(address,address,uint256)`; lifetime transfer statistics
45
+ // aggregate the raw ClickHouse `logs` stream by emitting token contract.
46
+ const transferTopic = AbiEvent.getSelector('event Transfer(address,address,uint256)')
47
+
48
+ /** Zod schemas owned by the token handlers. */
49
+ export namespace schema {
50
+ /** Schemas for the getToken operation. */
51
+ export namespace getToken {
52
+ /** Path parameters for token metadata requests. */
53
+ export const Params = z
54
+ .object({
55
+ token: Schema.TokenAddress.check(z.describe('The TIP-20 token contract address on Tempo.')),
56
+ })
57
+ .check(z.describe('Path parameters for looking up one token’s metadata.'))
58
+
59
+ /** Optional expensive fields that callers opt into via `include`. */
60
+ export const Include = z
61
+ .enum(['admin', 'createdAt', 'holderCount', 'quoteToken', 'transferStats'])
62
+ .check(
63
+ z.describe('Extra token details to calculate only when you request them with `include`.'),
64
+ )
65
+
66
+ /**
67
+ * Parses a comma-separated `include` query value into a list of optional
68
+ * fields. Expensive computations (the `createdAt`/`admin`/`quoteToken`
69
+ * indexer lookups, holder counts, and transfer statistics) are only run
70
+ * when explicitly requested, keeping the base response fast.
71
+ */
72
+ export const includeQuery = Schema.includeQuery(
73
+ Include,
74
+ 'Comma-separated extra token details to include, such as `admin,createdAt,holderCount,quoteToken,transferStats`.',
75
+ )
76
+
77
+ /** Query parameters for token metadata requests. */
78
+ export const Query = z
79
+ .object({
80
+ chainId: Schema.ChainIdQuery,
81
+ include: includeQuery,
82
+ })
83
+ .check(z.describe('Query parameters for looking up one token’s metadata.'))
84
+
85
+ /** TIP-20 token metadata returned by token endpoints. */
86
+ export const Response = Schema.describe(
87
+ z.object({
88
+ address: Schema.TokenAddress.check(
89
+ z.describe('The TIP-20 token contract address on Tempo.'),
90
+ ),
91
+ admin: z
92
+ .optional(Schema.Address)
93
+ .check(
94
+ z.describe(
95
+ 'Token admin from the onchain `TokenCreated` event, present when requested ' +
96
+ 'via `include=admin` and indexed data is available.',
97
+ ),
98
+ ),
99
+ currency: z
100
+ .string()
101
+ .check(
102
+ z.describe(
103
+ 'The currency label for this token, such as `USD` for USD-denominated stablecoins.',
104
+ ),
105
+ z.meta({ examples: ['USD'] }),
106
+ ),
107
+ createdAt: z
108
+ .optional(z.iso.datetime())
109
+ .check(
110
+ z.describe(
111
+ 'Token creation timestamp, present when requested via `include=createdAt` ' +
112
+ 'and indexed data is available.',
113
+ ),
114
+ z.meta({ examples: ['2024-01-01T00:00:00Z'] }),
115
+ ),
116
+ decimals: z
117
+ .number()
118
+ .check(
119
+ z.int(),
120
+ z.nonnegative(),
121
+ z.describe(
122
+ 'The number of decimal places the token uses; Tempo stablecoins typically use 6.',
123
+ ),
124
+ z.meta({ examples: [6] }),
125
+ ),
126
+ holderCount: z
127
+ .optional(z.number().check(z.int(), z.nonnegative()))
128
+ .check(
129
+ z.describe(
130
+ 'The number of accounts that currently hold a positive balance of this token, when indexed holder data is available.',
131
+ ),
132
+ z.meta({ examples: [1234] }),
133
+ ),
134
+ id: z
135
+ .string()
136
+ .check(
137
+ z.describe('A stable resource ID for this token, equal to its contract address.'),
138
+ z.meta({ examples: ['0x20c0000000000000000000008f5425160ebe5525'] }),
139
+ ),
140
+ logoUri: z.optional(z.string()).check(
141
+ z.describe('A URL for the token’s logo image, when one is available.'),
142
+ z.meta({
143
+ examples: [
144
+ 'https://api.tempo.xyz/data/4217/icons/0x20c0000000000000000000008f5425160ebe5525.svg',
145
+ ],
146
+ }),
147
+ ),
148
+ name: z
149
+ .string()
150
+ .check(
151
+ z.describe('The token’s human-readable name.'),
152
+ z.meta({ examples: ['USD Coin'] }),
153
+ ),
154
+ quoteToken: z
155
+ .optional(Schema.Address)
156
+ .check(
157
+ z.describe(
158
+ 'Quote token from the onchain `TokenCreated` event, present when requested ' +
159
+ 'via `include=quoteToken` and indexed data is available.',
160
+ ),
161
+ ),
162
+ symbol: z
163
+ .string()
164
+ .check(
165
+ z.describe('The short ticker symbol wallets and apps show for this token.'),
166
+ z.meta({ examples: ['USDC'] }),
167
+ ),
168
+ totalSupply: z
169
+ .optional(z.string().check(z.regex(/^\d+$/)))
170
+ .check(
171
+ z.describe(
172
+ 'The token’s total supply as a decimal string in the smallest unit, so large values keep full precision.',
173
+ ),
174
+ z.meta({ examples: ['1000000000000'] }),
175
+ ),
176
+ transferStats: z
177
+ .optional(
178
+ z.object({
179
+ count: z
180
+ .number()
181
+ .check(
182
+ z.int(),
183
+ z.nonnegative(),
184
+ z.describe('The total number of `Transfer` events emitted by this token.'),
185
+ z.meta({ examples: [12345] }),
186
+ ),
187
+ firstAt: z
188
+ .nullable(z.iso.datetime())
189
+ .check(
190
+ z.describe(
191
+ 'The time of this token’s first `Transfer` event, or `null` if no transfers exist.',
192
+ ),
193
+ z.meta({ examples: ['2024-01-01T00:00:00Z'] }),
194
+ ),
195
+ lastAt: z
196
+ .nullable(z.iso.datetime())
197
+ .check(
198
+ z.describe(
199
+ 'The time of this token’s most recent `Transfer` event, or `null` if no transfers exist.',
200
+ ),
201
+ z.meta({ examples: ['2024-06-01T12:00:00Z'] }),
202
+ ),
203
+ }),
204
+ )
205
+ .check(
206
+ z.describe(
207
+ 'Lifetime `Transfer` event statistics, present when requested via ' +
208
+ '`include=transferStats` and indexed data is available.',
209
+ ),
210
+ ),
211
+ verified: z
212
+ .boolean()
213
+ .check(
214
+ z.describe('Whether this token is in Tempo’s curated verified token list.'),
215
+ z.meta({ examples: [true] }),
216
+ ),
217
+ }),
218
+ 'Token metadata for a TIP-20 token, including name, symbol, decimals, supply, and optional indexed details.',
219
+ )
220
+ }
221
+
222
+ /**
223
+ * Trimmed TIP-20 token reference embedded in list/feed responses via
224
+ * `include=token`. Carries identity and display fields only; supply-level and
225
+ * temporal facts (`totalSupply`, `holderCount`, `createdAt`) belong on the
226
+ * token detail endpoint, not in feed rows.
227
+ */
228
+ export const Token = Schema.describe(
229
+ z.pick(getToken.Response, {
230
+ address: true,
231
+ currency: true,
232
+ decimals: true,
233
+ id: true,
234
+ logoUri: true,
235
+ name: true,
236
+ symbol: true,
237
+ verified: true,
238
+ }),
239
+ 'A compact TIP-20 token reference with identity and display fields only.',
240
+ )
241
+
242
+ /** Schemas for the getTokenBySymbol operation. */
243
+ export namespace getTokenBySymbol {
244
+ /** Hono route pattern for verified token symbols. */
245
+ // Symbols must start with a letter so this route cannot claim address-shaped
246
+ // paths before the address validator gets a chance to report token errors.
247
+ export const routePattern = '[A-Za-z][A-Za-z0-9._]{0,63}'
248
+
249
+ const pattern = new RegExp(`^${routePattern}$`)
250
+
251
+ /** Path parameters for token symbol metadata requests. */
252
+ export const Params = z
253
+ .object({
254
+ symbol: z.string().check(
255
+ z.regex(pattern),
256
+ z.describe('The symbol of a token in Tempo’s verified token list.'),
257
+ z.meta({
258
+ examples: ['USDC.e'],
259
+ }),
260
+ ),
261
+ })
262
+ .check(z.describe('Path parameters for looking up a verified token by symbol.'))
263
+ }
264
+
265
+ /** Schemas for the getTokenLogo operation. */
266
+ export namespace getTokenLogo {
267
+ /** Path parameters for token logo image requests. */
268
+ export const Params = z
269
+ .object({
270
+ token: Schema.TokenAddress.check(z.describe('The TIP-20 token contract address on Tempo.')),
271
+ })
272
+ .check(z.describe('Path parameters for fetching a token logo image.'))
273
+
274
+ /** Query parameters for token logo image requests. */
275
+ export const Query = z
276
+ .object({
277
+ chainId: Schema.ChainIdQuery,
278
+ })
279
+ .check(z.describe('Query parameters for fetching a token logo image.'))
280
+ }
281
+
282
+ /** Schemas for the getTokens operation. */
283
+ export namespace getTokens {
284
+ /** Query parameters for token list requests. */
285
+ export const Query = z
286
+ .strictObject({
287
+ addresses: z
288
+ .optional(
289
+ z.pipe(
290
+ // Bare-transform input stage, opaque to JSON-schema generation
291
+ // (the `meta` below documents the parameter): accepts the comma
292
+ // form and the repeated form (`?addresses=a&addresses=b`), which
293
+ // the validator delivers as a `string[]`.
294
+ z.transform((value) =>
295
+ (Array.isArray(value) ? value : [value])
296
+ .flatMap((entry) => String(entry).split(','))
297
+ .map((entry) => entry.trim())
298
+ .filter(Boolean),
299
+ ),
300
+ z.array(Schema.TokenAddress).check(z.maxLength(50)),
301
+ ),
302
+ )
303
+ .check(
304
+ z.meta({
305
+ type: 'array',
306
+ items: { type: 'string' },
307
+ examples: [['0x20c0000000000000000000008f5425160ebe5525']],
308
+ }),
309
+ z.describe(
310
+ 'Comma-separated token contract addresses to fetch (max 50). Returns a ' +
311
+ 'single page in input order (unresolvable addresses omitted); `cursor`, ' +
312
+ '`page`, and `order` are inapplicable. Combines with `include`; ' +
313
+ '`currency` and `verified` further filter the resolved set.',
314
+ ),
315
+ ),
316
+ chainId: Schema.ChainIdQuery,
317
+ currency: z
318
+ .optional(z.string())
319
+ .check(
320
+ z.meta({ examples: VerifiedTokens.currencies }),
321
+ z.describe(
322
+ 'Only include tokens denominated in this currency (e.g. `USD`). ' +
323
+ 'Case-insensitive. Matched against the `currency` field of the onchain ' +
324
+ '`TokenCreated` event, so any string a token deployer wrote is acceptable; ' +
325
+ 'the listed examples are the well-known curated currencies.',
326
+ ),
327
+ ),
328
+ cursor: Schema.Cursor,
329
+ include: getToken.includeQuery,
330
+ limit: Schema.Limit,
331
+ order: Schema.Order,
332
+ page: Schema.Page,
333
+ verified: z
334
+ .optional(Schema.booleanQuery())
335
+ .check(
336
+ z.describe(
337
+ 'When `true`, only return tokens in the curated verified list. ' +
338
+ '`currency`, `include`, and the pagination parameters (`limit`, ' +
339
+ '`page`, `cursor`, `order`) all apply; the list is paginated ' +
340
+ 'positionally over its canonical order (`order=asc` reverses it).',
341
+ ),
342
+ z.meta({ examples: [true] }),
343
+ ),
344
+ })
345
+ .check(...Schema.pageChecks(), z.describe('Query parameters for listing TIP-20 tokens.'))
346
+
347
+ /** Page of TIP-20 tokens. */
348
+ export const Response = z
349
+ .object({
350
+ data: z.array(getToken.Response).check(z.describe('The tokens in this page.')),
351
+ nextCursor: Schema.NextCursor,
352
+ })
353
+ .check(z.describe('A page of TIP-20 tokens on Tempo.'))
354
+ }
355
+
356
+ /** Schemas for the getTokenHolders operation. */
357
+ export namespace getTokenHolders {
358
+ /** Path parameters for token holder requests. */
359
+ export const Params = z
360
+ .object({
361
+ token: Schema.TokenAddress.check(z.describe('The TIP-20 token contract address on Tempo.')),
362
+ })
363
+ .check(z.describe('Path parameters for listing a token’s holders.'))
364
+
365
+ /** Optional related resources that callers opt into via `include`. */
366
+ export const Include = z
367
+ .enum(['token', 'totalCount'])
368
+ .check(z.describe('Related resources to include only when you request them with `include`.'))
369
+
370
+ /**
371
+ * Parses a comma-separated `include` query value into a list of optional
372
+ * resources to embed. Extra lookups (token metadata, the holder total) only
373
+ * run when explicitly requested, keeping the base holder page fast.
374
+ */
375
+ export const includeQuery = Schema.includeQuery(
376
+ Include,
377
+ 'Comma-separated related resources to include, such as `token,totalCount`.',
378
+ )
379
+
380
+ /** Query parameters for token holder requests. */
381
+ export const Query = z
382
+ .strictObject({
383
+ chainId: Schema.ChainIdQuery,
384
+ cursor: Schema.Cursor,
385
+ include: includeQuery,
386
+ limit: Schema.Limit,
387
+ page: Schema.Page,
388
+ })
389
+ .check(...Schema.pageChecks(), z.describe('Query parameters for listing a token’s holders.'))
390
+
391
+ /** A single TIP-20 token holder. */
392
+ export const Holder = z
393
+ .object({
394
+ address: Schema.Address.check(z.describe('The account address that holds this token.')),
395
+ balance: z
396
+ .string()
397
+ .check(
398
+ z.regex(/^\d+$/),
399
+ z.describe(
400
+ 'A non-negative whole number, given as a decimal string so very large token amounts keep full precision. Expressed in the smallest unit of the token.',
401
+ ),
402
+ z.meta({ examples: ['1000000'] }),
403
+ ),
404
+ id: z
405
+ .string()
406
+ .check(
407
+ z.describe('A stable resource ID for this holder, equal to the holder address.'),
408
+ z.meta({ examples: ['0xbe058e1c4df8a4366a387bf595b284246a93039e'] }),
409
+ ),
410
+ })
411
+ .check(z.describe('One account holding a positive balance of this TIP-20 token.'))
412
+
413
+ /** Resources embedded on demand via `include`. */
414
+ export const Meta = z
415
+ .object({
416
+ totalCountCapped: z
417
+ .optional(Schema.TotalCountCapped)
418
+ .check(
419
+ z.describe(
420
+ 'Whether `totalCount` reached the API count cap; holder counts are exact here.',
421
+ ),
422
+ ),
423
+ token: z
424
+ .optional(Token)
425
+ .check(
426
+ z.describe('A compact token reference, returned when you request `include=token`.'),
427
+ ),
428
+ totalCount: z
429
+ .optional(Schema.TotalCount)
430
+ .check(
431
+ z.describe(
432
+ 'The total number of token holders, returned when you request `include=totalCount`.',
433
+ ),
434
+ ),
435
+ })
436
+ .check(z.describe('Extra resources included because you requested them with `include`.'))
437
+
438
+ /** Page of TIP-20 token holders, ordered by balance descending. */
439
+ export const Response = z
440
+ .object({
441
+ data: z
442
+ .array(Holder)
443
+ .check(
444
+ z.describe('The holders in this page, ordered by balance from highest to lowest.'),
445
+ ),
446
+ meta: z.optional(Meta).check(z.describe('Extra resources you requested with `include`.')),
447
+ nextCursor: Schema.NextCursor,
448
+ })
449
+ .check(z.describe('A page of accounts holding this TIP-20 token.'))
450
+ }
451
+
452
+ /** Schemas for the getTokenTransactions operation. */
453
+ export namespace getTokenTransactions {
454
+ /** Path parameters for token transaction requests. */
455
+ export const Params = z
456
+ .object({
457
+ token: Schema.TokenAddress.check(z.describe('The TIP-20 token contract address on Tempo.')),
458
+ })
459
+ .check(z.describe('Path parameters for listing transactions involving this token contract.'))
460
+
461
+ /** Optional related resources that callers opt into via `include`. */
462
+ export const Include = z
463
+ .enum(['feeToken', 'token', 'totalCount'])
464
+ .check(z.describe('Related resources to include only when you request them with `include`.'))
465
+
466
+ /**
467
+ * Parses a comma-separated `include` query value into a list of optional
468
+ * resources to embed. Extra lookups only run when explicitly requested,
469
+ * keeping the base transaction page fast.
470
+ */
471
+ export const includeQuery = Schema.includeQuery(
472
+ Include,
473
+ 'Comma-separated related resources to include, such as `feeToken,token,totalCount`.',
474
+ )
475
+
476
+ /** Query parameters for token transaction requests. */
477
+ export const Query = z
478
+ .strictObject({
479
+ 'blockNumber.from': Schema.blockNumberBound('transactions', 'from'),
480
+ 'blockNumber.to': Schema.blockNumberBound('transactions', 'to'),
481
+ chainId: Schema.ChainIdQuery,
482
+ cursor: Schema.Cursor,
483
+ feePayer: z
484
+ .optional(Schema.Address)
485
+ .check(z.describe('Only include transactions where this account paid the fee.')),
486
+ feeToken: z
487
+ .optional(Schema.TokenAddress)
488
+ .check(
489
+ z.describe(
490
+ 'Only include transactions whose fee was paid in this token. On Tempo, fees are paid in USD stablecoins instead of a separate volatile gas token.',
491
+ ),
492
+ ),
493
+ include: includeQuery,
494
+ limit: Schema.Limit,
495
+ order: Schema.Order,
496
+ page: Schema.Page,
497
+ 'timestamp.from': Schema.timestampBound('transactions', 'from'),
498
+ 'timestamp.to': Schema.timestampBound('transactions', 'to'),
499
+ })
500
+ .check(
501
+ ...Schema.pageChecks(),
502
+ z.describe('Query parameters for listing transactions involving this token contract.'),
503
+ )
504
+
505
+ /** Resources embedded on demand via `include`. */
506
+ export const Meta = z
507
+ .object({
508
+ totalCountCapped: z
509
+ .optional(Schema.TotalCountCapped)
510
+ .check(
511
+ z.describe(
512
+ 'Whether `totalCount` reached the API count cap; when `true`, treat the count as a lower bound.',
513
+ ),
514
+ ),
515
+ token: z
516
+ .optional(Token)
517
+ .check(
518
+ z.describe('A compact token reference, returned when you request `include=token`.'),
519
+ ),
520
+ totalCount: z
521
+ .optional(Schema.TotalCount)
522
+ .check(
523
+ z.describe(
524
+ 'The capped total number of matching transactions, returned when you request `include=totalCount`.',
525
+ ),
526
+ ),
527
+ })
528
+ .check(z.describe('Extra resources included because you requested them with `include`.'))
529
+
530
+ /**
531
+ * Page of transactions touching the token contract. Each row is the
532
+ * humanized transaction shape returned by `GET /transactions/:transactionHash`;
533
+ * the optional `meta.token` embed is the token being scoped to.
534
+ *
535
+ * The `data` schema is wrapped in `z.lazy` because `transactions.ts`
536
+ * already imports `Tokens.schema` at module init; a direct reference
537
+ * here would close the import cycle and crash during evaluation.
538
+ */
539
+ export const Response = z
540
+ .object({
541
+ data: z
542
+ .array(z.lazy(() => Transactions.schema.getTransaction.Response))
543
+ .check(z.describe('The transactions in this page.')),
544
+ meta: z.optional(Meta).check(z.describe('Extra resources you requested with `include`.')),
545
+ nextCursor: Schema.NextCursor,
546
+ })
547
+ .check(z.describe('A page of transactions that interact with this token contract.'))
548
+ }
549
+ }
550
+
551
+ /** Creates token handlers. */
552
+ export function tokens() {
553
+ return new Hono<App.Environment>()
554
+ .get(
555
+ '/',
556
+ Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }),
557
+ OpenApi.validate('query', schema.getTokens.Query, {
558
+ code: 'query_invalid',
559
+ message: 'Invalid query parameters',
560
+ }),
561
+ OpenApi.describeRoute({
562
+ description:
563
+ 'Lists TIP-20 tokens on Tempo. TIP-20 is Tempo’s payments-focused token standard and a superset of ERC-20.',
564
+ operationId: 'getTokens',
565
+ responses: OpenApi.responses({
566
+ success: { description: 'A page of TIP-20 tokens.', schema: schema.getTokens.Response },
567
+ }),
568
+ summary: 'List tokens',
569
+ tags: ['Tokens'],
570
+ }),
571
+ Cache.response<App.Environment>({
572
+ cacheControl: Cache.policies.metadata,
573
+ name: 'tempo-api:tokens:v1',
574
+ // Verified pages are served from the curated snapshot, not the indexer,
575
+ // so their cached entry must depend on the snapshot version — otherwise
576
+ // a publish (create/patch/remove/replace) or reseed never invalidates
577
+ // the page and a stale list is served indefinitely. Mirrors the
578
+ // address-balances route. Non-verified pages stay URL-keyed.
579
+ key: async (c) => {
580
+ const base = Cache.urlKey(c, schema.getTokens.Query)
581
+ const query = schema.getTokens.Query.parse(
582
+ Object.fromEntries(new URL(c.req.url).searchParams),
583
+ )
584
+ if (!query.verified) return base
585
+ const chainId = query.chainId ?? c.get('chainId')
586
+ const { version } = await VerifiedTokens.snapshot(c, chainId)
587
+ return `${base}:v:${version}`
588
+ },
589
+ }),
590
+ async (c) => {
591
+ if (Auth.narrowAccess) return Auth.paidAccessError(c)
592
+ if (OpenApi.narrowValidation)
593
+ return OpenApi.validationError(c, {
594
+ code: 'query_invalid',
595
+ message: 'Invalid query parameters',
596
+ })
597
+
598
+ const query = c.req.valid('query')
599
+
600
+ try {
601
+ const chainId = query.chainId ?? c.get('chainId')
602
+ return c.json(
603
+ Response.validated(
604
+ schema.getTokens.Response,
605
+ await getTokens(c, {
606
+ addresses: query.addresses,
607
+ chainId,
608
+ currency: query.currency,
609
+ cursor: query.cursor,
610
+ include: query.include,
611
+ limit: query.limit,
612
+ order: query.order,
613
+ page: query.page,
614
+ verified: query.verified,
615
+ }),
616
+ ),
617
+ 200,
618
+ )
619
+ } catch (cause) {
620
+ return Response.upstream(c, cause)
621
+ }
622
+ },
623
+ )
624
+ .get(
625
+ `/:symbol{${schema.getTokenBySymbol.routePattern}}`,
626
+ Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }),
627
+ OpenApi.validate('param', schema.getTokenBySymbol.Params, {
628
+ code: 'symbol_invalid',
629
+ message: 'Invalid token symbol',
630
+ }),
631
+ OpenApi.validate('query', schema.getToken.Query, {
632
+ code: 'query_invalid',
633
+ message: 'Invalid query parameters',
634
+ }),
635
+ OpenApi.describeRoute({
636
+ description:
637
+ 'Returns token metadata for a verified TIP-20 token using its symbol, such as `USDC`.',
638
+ operationId: 'getTokenBySymbol',
639
+ responses: OpenApi.responses({
640
+ errors: {
641
+ 404: {
642
+ description: 'No token was found for this address or symbol.',
643
+ codes: ['token_not_found'],
644
+ },
645
+ 502: 'An upstream RPC or indexer request failed while resolving this token.',
646
+ },
647
+ success: {
648
+ description: 'Metadata for one TIP-20 token.',
649
+ schema: schema.getToken.Response,
650
+ },
651
+ }),
652
+ summary: 'Get token by symbol',
653
+ tags: ['Tokens'],
654
+ }),
655
+ Cache.response<App.Environment>({
656
+ cacheControl: Cache.policies.metadata,
657
+ name: 'tempo-api:tokens:v1',
658
+ key: (c) => Cache.urlKey(c, schema.getToken.Query),
659
+ }),
660
+ async (c) => {
661
+ if (Auth.narrowAccess) return Auth.paidAccessError(c)
662
+ if (OpenApi.narrowValidation)
663
+ return OpenApi.validationError(c, {
664
+ code: 'symbol_invalid',
665
+ message: 'Invalid request parameters',
666
+ })
667
+ if (OpenApi.narrowValidation)
668
+ return OpenApi.validationError(c, {
669
+ code: 'query_invalid',
670
+ message: 'Invalid request parameters',
671
+ })
672
+
673
+ const { symbol } = c.req.valid('param')
674
+ const query = c.req.valid('query')
675
+ const chainId = query.chainId ?? c.get('chainId')
676
+ try {
677
+ const snapshot = await VerifiedTokens.snapshot(c, chainId)
678
+ const token = snapshot.bySymbol.get(symbol.toLowerCase())
679
+ if (token)
680
+ return getToken(c, {
681
+ address: token.address,
682
+ chainId,
683
+ include: query.include,
684
+ snapshot,
685
+ })
686
+ return Response.error(c, {
687
+ code: 'token_not_found',
688
+ message: 'Token not found',
689
+ status: 404,
690
+ })
691
+ } catch (cause) {
692
+ return Response.upstream(c, cause)
693
+ }
694
+ },
695
+ )
696
+ .get(
697
+ '/:token',
698
+ Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }),
699
+ OpenApi.validate('param', schema.getToken.Params, {
700
+ code: 'token_invalid',
701
+ message: 'Invalid token address',
702
+ }),
703
+ OpenApi.validate('query', schema.getToken.Query, {
704
+ code: 'query_invalid',
705
+ message: 'Invalid query parameters',
706
+ }),
707
+ OpenApi.describeRoute({
708
+ description:
709
+ 'Returns token metadata for a TIP-20 contract address, whether or not the token is in the verified list.',
710
+ operationId: 'getToken',
711
+ responses: OpenApi.responses({
712
+ errors: {
713
+ 404: {
714
+ description: 'No token was found for this address or symbol.',
715
+ codes: ['token_not_found'],
716
+ },
717
+ 502: 'An upstream RPC or indexer request failed while resolving this token.',
718
+ },
719
+ success: {
720
+ description: 'Metadata for one TIP-20 token.',
721
+ schema: schema.getToken.Response,
722
+ },
723
+ }),
724
+ summary: 'Get token by address',
725
+ tags: ['Tokens'],
726
+ }),
727
+ Cache.response<App.Environment>({
728
+ cacheControl: Cache.policies.metadata,
729
+ name: 'tempo-api:tokens:v1',
730
+ key: (c) => Cache.urlKey(c, schema.getToken.Query),
731
+ }),
732
+ async (c) => {
733
+ if (Auth.narrowAccess) return Auth.paidAccessError(c)
734
+ if (OpenApi.narrowValidation)
735
+ return OpenApi.validationError(c, {
736
+ code: 'token_invalid',
737
+ message: 'Invalid request parameters',
738
+ })
739
+ if (OpenApi.narrowValidation)
740
+ return OpenApi.validationError(c, {
741
+ code: 'query_invalid',
742
+ message: 'Invalid request parameters',
743
+ })
744
+
745
+ const { token: address } = c.req.valid('param')
746
+ const query = c.req.valid('query')
747
+ const chainId = query.chainId ?? c.get('chainId')
748
+ return getToken(c, { address, chainId, include: query.include })
749
+ },
750
+ )
751
+ .get(
752
+ '/:token/logo',
753
+ Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }),
754
+ OpenApi.validate('param', schema.getTokenLogo.Params, {
755
+ code: 'token_invalid',
756
+ message: 'Invalid token address',
757
+ }),
758
+ OpenApi.validate('query', schema.getTokenLogo.Query, {
759
+ code: 'query_invalid',
760
+ message: 'Invalid query parameters',
761
+ }),
762
+ OpenApi.describeRoute({
763
+ description:
764
+ 'Returns the logo image for a TIP-20 token, using Tempo’s curated asset when available.',
765
+ operationId: 'getTokenLogo',
766
+ responses: {
767
+ 200: {
768
+ content: { 'image/*': {} },
769
+ description: 'The token logo image.',
770
+ headers: OpenApi.successHeaders,
771
+ },
772
+ 400: OpenApi.standardError(400, 'The request parameters were invalid.'),
773
+ 401: OpenApi.standardError(401, 'The API key is missing or invalid.'),
774
+ 404: OpenApi.standardError(404, 'No logo image was found for this token.', [
775
+ 'token_logo_not_found',
776
+ ]),
777
+ 429: OpenApi.standardError(429, 'Too many requests; wait and try again.'),
778
+ 500: OpenApi.standardError(500, 'The API encountered an internal error.'),
779
+ 502: OpenApi.standardError(
780
+ 502,
781
+ 'An upstream RPC request failed while fetching the logo.',
782
+ ),
783
+ },
784
+ summary: 'Get token logo',
785
+ tags: ['Tokens'],
786
+ }),
787
+ // Publish the `asset` (forever, public) policy so the pre-auth edge cache
788
+ // serves logo bytes to every caller without re-running the handler — the
789
+ // bytes carry no per-principal data. `vary` is `Accept-Encoding` only so a
790
+ // single shared entry serves authenticated and anonymous callers alike.
791
+ Cache.response<App.Environment>({
792
+ cacheControl: Cache.policies.asset,
793
+ name: 'tempo-api:tokens:v1',
794
+ key: (c) => Cache.urlKey(c, schema.getTokenLogo.Query),
795
+ vary: ['Accept-Encoding'],
796
+ }),
797
+ async (c) => {
798
+ if (Auth.narrowAccess) return Auth.paidAccessError(c)
799
+ if (OpenApi.narrowValidation)
800
+ return OpenApi.validationError(c, {
801
+ code: 'token_invalid',
802
+ message: 'Invalid request parameters',
803
+ })
804
+ if (OpenApi.narrowValidation)
805
+ return OpenApi.validationError(c, {
806
+ code: 'query_invalid',
807
+ message: 'Invalid request parameters',
808
+ })
809
+
810
+ const { token: address } = c.req.valid('param')
811
+ const query = c.req.valid('query')
812
+ const chainId = query.chainId ?? c.get('chainId')
813
+
814
+ try {
815
+ const logo = await Timing.time(c, 'token_logo', () =>
816
+ fetchTokenLogo(c, { address, chainId }),
817
+ )
818
+ if (!logo)
819
+ return Response.error(c, {
820
+ code: 'token_logo_not_found',
821
+ message: 'Token logo not found',
822
+ status: 404,
823
+ })
824
+ // `Cache.response` (above) stamps the `asset` Cache-Control / Vary;
825
+ // only the content type is set here.
826
+ return c.body(logo.body, 200, { 'Content-Type': logo.contentType })
827
+ } catch (cause) {
828
+ // A token whose on-chain metadata read reverts (unregistered TIP-20)
829
+ // has no logo to serve — map it to the route's `404`, not a `502`,
830
+ // mirroring `getToken`. Only genuine upstream failures surface as 502.
831
+ if (isTokenNotFound(cause))
832
+ return Response.error(c, {
833
+ code: 'token_logo_not_found',
834
+ message: 'Token logo not found',
835
+ status: 404,
836
+ })
837
+ return Response.upstream(c, cause)
838
+ }
839
+ },
840
+ )
841
+ .get(
842
+ '/:token/holders',
843
+ Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }),
844
+ OpenApi.validate('param', schema.getTokenHolders.Params, {
845
+ code: 'token_invalid',
846
+ message: 'Invalid token address',
847
+ }),
848
+ OpenApi.validate('query', schema.getTokenHolders.Query, {
849
+ code: 'query_invalid',
850
+ message: 'Invalid query parameters',
851
+ }),
852
+ OpenApi.describeRoute({
853
+ description:
854
+ 'Lists the accounts that hold a TIP-20 token, ordered from largest to smallest balance.',
855
+ operationId: 'getTokenHolders',
856
+ responses: OpenApi.responses({
857
+ errors: { 502: 'The upstream indexer could not serve this request.' },
858
+ success: {
859
+ description: 'A page of token holders.',
860
+ schema: schema.getTokenHolders.Response,
861
+ },
862
+ }),
863
+ summary: 'List token holders',
864
+ tags: ['Tokens'],
865
+ }),
866
+ Cache.response<App.Environment>({
867
+ cacheControl: Cache.policies.state,
868
+ name: 'tempo-api:tokens:v1',
869
+ key: (c) => Cache.urlKey(c, schema.getTokenHolders.Query),
870
+ }),
871
+ async (c) => {
872
+ if (Auth.narrowAccess) return Auth.paidAccessError(c)
873
+ if (OpenApi.narrowValidation)
874
+ return OpenApi.validationError(c, {
875
+ code: 'token_invalid',
876
+ message: 'Invalid request parameters',
877
+ })
878
+ if (OpenApi.narrowValidation)
879
+ return OpenApi.validationError(c, {
880
+ code: 'query_invalid',
881
+ message: 'Invalid request parameters',
882
+ })
883
+
884
+ const { token: address } = c.req.valid('param')
885
+ const query = c.req.valid('query')
886
+
887
+ try {
888
+ const chainId = query.chainId ?? c.get('chainId')
889
+ // Resolve the holder page and (opt-in) token metadata concurrently so
890
+ // the token lookup is hidden behind the slower holder aggregation
891
+ // rather than adding to it.
892
+ const includeToken = query.include.includes('token')
893
+ // `totalCount` is the holder total, read exactly from the
894
+ // pre-aggregated `token_holder_counts` view (not a capped scan), so
895
+ // `totalCountCapped` is always false. Best-effort: `getHolderCount` returns
896
+ // null when unavailable, in which case the count is omitted.
897
+ const includeTotalCount = query.include.includes('totalCount')
898
+ const [holders, token, holderCount] = await Promise.all([
899
+ getTokenHolders(c, {
900
+ address,
901
+ chainId,
902
+ cursor: query.cursor,
903
+ limit: query.limit,
904
+ page: query.page,
905
+ }),
906
+ includeToken
907
+ ? Timing.time(c, 'token', () => resolveToken(c, { address, chainId }))
908
+ : Promise.resolve(undefined),
909
+ includeTotalCount
910
+ ? Timing.time(c, 'token_holders_count', () => getHolderCount(c, { address, chainId }))
911
+ : Promise.resolve(null),
912
+ ])
913
+ // Embedded resources requested via `include` live under `meta`,
914
+ // separate from the holder page (`data`) and pagination fields. `meta`
915
+ // is omitted entirely when nothing response-wide was requested.
916
+ const meta = {
917
+ ...(token ? { token } : {}),
918
+ ...(holderCount !== null ? { totalCountCapped: false, totalCount: holderCount } : {}),
919
+ }
920
+ return c.json(
921
+ Response.validated(schema.getTokenHolders.Response, {
922
+ ...holders,
923
+ meta: Object.keys(meta).length > 0 ? meta : undefined,
924
+ }),
925
+ 200,
926
+ )
927
+ } catch (cause) {
928
+ return Response.upstream(c, cause)
929
+ }
930
+ },
931
+ )
932
+ .get(
933
+ '/:token/transactions',
934
+ Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }),
935
+ OpenApi.validate('param', schema.getTokenTransactions.Params, {
936
+ code: 'token_invalid',
937
+ message: 'Invalid token address',
938
+ }),
939
+ OpenApi.validate('query', schema.getTokenTransactions.Query, {
940
+ code: 'query_invalid',
941
+ message: 'Invalid query parameters',
942
+ }),
943
+ OpenApi.describeRoute({
944
+ description: 'Lists transactions that interacted with this token contract.',
945
+ operationId: 'getTokenTransactions',
946
+ responses: OpenApi.responses({
947
+ errors: { 502: 'The upstream indexer could not serve this request.' },
948
+ success: {
949
+ description: 'A page of transactions involving this token contract.',
950
+ schema: schema.getTokenTransactions.Response,
951
+ },
952
+ }),
953
+ summary: 'List token transactions',
954
+ tags: ['Transactions'],
955
+ }),
956
+ Cache.response<App.Environment>({
957
+ cacheControl: Cache.policies.feed,
958
+ name: 'tempo-api:tokens:v1',
959
+ key: (c) => Cache.urlKey(c, schema.getTokenTransactions.Query),
960
+ }),
961
+ async (c) => {
962
+ if (Auth.narrowAccess) return Auth.paidAccessError(c)
963
+ if (OpenApi.narrowValidation)
964
+ return OpenApi.validationError(c, {
965
+ code: 'token_invalid',
966
+ message: 'Invalid request parameters',
967
+ })
968
+ if (OpenApi.narrowValidation)
969
+ return OpenApi.validationError(c, {
970
+ code: 'query_invalid',
971
+ message: 'Invalid request parameters',
972
+ })
973
+
974
+ const { token: address } = c.req.valid('param')
975
+ const query = c.req.valid('query')
976
+
977
+ // Cursor pages are anchored below the head and effectively immutable,
978
+ // so cache them aggressively; the head page keeps the `feed` default.
979
+ if (query.cursor !== undefined) Cache.setPolicy(c, Cache.policies.immutable)
980
+
981
+ try {
982
+ const chainId = query.chainId ?? c.get('chainId')
983
+ // Resolve the transaction page and (opt-in) token metadata
984
+ // concurrently so the token lookup is hidden behind the
985
+ // transaction query rather than adding to it. The `feeToken`
986
+ // include flows through to `listTransactions` for per-row embeds;
987
+ // `token` is a root-level embed for the token being scoped to.
988
+ const includeToken = query.include.includes('token')
989
+ // `totalCount` is opt-in; count transactions touching this token
990
+ // (address-scoped to the contract) under the same filters as the
991
+ // page. Best-effort: a count failure omits the count from `meta`.
992
+ const countPromise = query.include.includes('totalCount')
993
+ ? Transactions.countTransactions(c, {
994
+ address,
995
+ chainId,
996
+ feePayer: query.feePayer,
997
+ feeToken: query.feeToken,
998
+ fromBlock: query['blockNumber.from'],
999
+ fromTimestamp: query['timestamp.from'],
1000
+ toBlock: query['blockNumber.to'],
1001
+ toTimestamp: query['timestamp.to'],
1002
+ }).catch(() => undefined)
1003
+ : undefined
1004
+ const [transactions, token, count] = await Promise.all([
1005
+ Transactions.listTransactions(c, {
1006
+ address,
1007
+ chainId,
1008
+ cursor: query.cursor,
1009
+ feePayer: query.feePayer,
1010
+ feeToken: query.feeToken,
1011
+ fromBlock: query['blockNumber.from'],
1012
+ fromTimestamp: query['timestamp.from'],
1013
+ include: query.include.includes('feeToken') ? ['feeToken'] : [],
1014
+ limit: query.limit,
1015
+ order: query.order,
1016
+ page: query.page,
1017
+ toBlock: query['blockNumber.to'],
1018
+ toTimestamp: query['timestamp.to'],
1019
+ }),
1020
+ includeToken
1021
+ ? Timing.time(c, 'token', () => resolveToken(c, { address, chainId }))
1022
+ : Promise.resolve(undefined),
1023
+ countPromise,
1024
+ ])
1025
+ const meta = {
1026
+ ...(token ? { token } : {}),
1027
+ ...(count
1028
+ ? { totalCountCapped: count.totalCountCapped, totalCount: count.totalCount }
1029
+ : {}),
1030
+ }
1031
+ return c.json(
1032
+ Response.validated(schema.getTokenTransactions.Response, {
1033
+ ...transactions,
1034
+ meta: Object.keys(meta).length > 0 ? meta : undefined,
1035
+ }),
1036
+ 200,
1037
+ )
1038
+ } catch (cause) {
1039
+ return Response.upstream(c, cause)
1040
+ }
1041
+ },
1042
+ )
1043
+ }
1044
+
1045
+ async function getToken(c: Context<App.Environment>, options: getToken.Options) {
1046
+ return Timing.time(c, 'token', async () => {
1047
+ try {
1048
+ return c.json(await resolveToken(c, options), 200)
1049
+ } catch (cause) {
1050
+ if (isTokenNotFound(cause))
1051
+ return Response.error(c, {
1052
+ code: 'token_not_found',
1053
+ message: 'Token not found',
1054
+ status: 404,
1055
+ })
1056
+ return Response.upstream(c, cause)
1057
+ }
1058
+ })
1059
+ }
1060
+
1061
+ declare namespace getToken {
1062
+ type Options = {
1063
+ address: z.output<typeof Schema.Address>
1064
+ chainId: z.output<typeof Schema.ChainId>
1065
+ createdAtByAddress?: Record<string, string> | undefined
1066
+ /** Pre-fetched `TokenCreated` extras (`admin`/`quoteToken`) for page callers. */
1067
+ createdByAddress?: Record<string, getTokenCreatedByAddress.Created> | undefined
1068
+ /** Pre-fetched holder counts for page callers. */
1069
+ holderCountByAddress?: Record<string, number> | undefined
1070
+ include?: readonly z.output<typeof schema.getToken.Include>[] | undefined
1071
+ snapshot?: VerifiedTokens.Snapshot | undefined
1072
+ /** Pre-fetched transfer statistics for page callers. */
1073
+ transferStatsByAddress?: Record<string, getTransferStats.Stats> | undefined
1074
+ }
1075
+ }
1076
+
1077
+ /**
1078
+ * Resolves the token metadata object. Throws on upstream failure / not found so
1079
+ * callers can map the error (e.g. `getToken` → 404/502). Shared by `getToken`,
1080
+ * the holder page's `include=token`, and the per-row address balance `token`.
1081
+ *
1082
+ * Page-level callers that enrich many tokens at once should pre-fetch
1083
+ * `createdAtByAddress` (one `IN (...)` TIDX query for the whole page) and pass
1084
+ * it in to avoid N separate `getTokenCreatedAtByAddress` round-trips. The same
1085
+ * applies to the verified-token `snapshot` and the other batch enrichments
1086
+ * (`createdByAddress`, `holderCountByAddress`, `transferStatsByAddress`).
1087
+ */
1088
+ export async function resolveToken(
1089
+ c: Context<App.Environment>,
1090
+ options: resolveToken.Options,
1091
+ ): Promise<z.output<typeof schema.getToken.Response>> {
1092
+ const { chainId } = options
1093
+ // Normalize once so address and symbol lookups share cache entries and
1094
+ // verified-data comparisons do not depend on caller casing.
1095
+ const address = Schema.Address.parse(options.address.toLowerCase())
1096
+
1097
+ // `createdAt`, the `TokenCreated` extras, holder counts, and transfer stats
1098
+ // require an expensive indexer round-trip, so each only runs when the caller
1099
+ // opts in via `?include=...`. Page-level callers can still pre-fetch the
1100
+ // batch maps for the whole page and pass them in regardless of `include`.
1101
+ const includeAdmin = options.include?.includes('admin') ?? false
1102
+ const includeCreatedAt = options.include?.includes('createdAt') ?? false
1103
+ const includeHolderCount = options.include?.includes('holderCount') ?? false
1104
+ const includeQuoteToken = options.include?.includes('quoteToken') ?? false
1105
+ const includeTransferStats = options.include?.includes('transferStats') ?? false
1106
+
1107
+ const [metadata, snapshot, createdAtByAddress, createdByAddress, logo, holderCount, stats] =
1108
+ await Promise.all([
1109
+ Timing.time(c, 'token_metadata', () => getTokenMetadata(c, { address, chainId })),
1110
+ options.snapshot ?? VerifiedTokens.snapshot(c, chainId),
1111
+ options.createdAtByAddress ??
1112
+ (includeCreatedAt
1113
+ ? Timing.time(c, 'token_created', () =>
1114
+ getTokenCreatedAtByAddress(c, { addresses: [address], chainId }),
1115
+ )
1116
+ : Promise.resolve({} as Record<string, string>)),
1117
+ // `admin` and `quoteToken` ride one `TokenCreated` fetch: either include
1118
+ // triggers it, and each field still surfaces independently below.
1119
+ options.createdByAddress ??
1120
+ (includeAdmin || includeQuoteToken
1121
+ ? Timing.time(c, 'token_created_extras', () =>
1122
+ getTokenCreatedByAddress(c, { addresses: [address], chainId }),
1123
+ )
1124
+ : Promise.resolve({} as Record<string, getTokenCreatedByAddress.Created>)),
1125
+ Timing.time(c, 'token_logo', () => getTokenLogo(c, { address, chainId })),
1126
+ options.holderCountByAddress
1127
+ ? Promise.resolve(options.holderCountByAddress[address] ?? null)
1128
+ : includeHolderCount
1129
+ ? Timing.time(c, 'token_holders', () => getHolderCount(c, { address, chainId }))
1130
+ : Promise.resolve(undefined),
1131
+ options.transferStatsByAddress
1132
+ ? Promise.resolve(options.transferStatsByAddress[address] ?? null)
1133
+ : includeTransferStats
1134
+ ? Timing.time(c, 'token_transfers', () => getTransferStats(c, { address, chainId }))
1135
+ : Promise.resolve(undefined),
1136
+ ])
1137
+
1138
+ const created = createdByAddress[address]
1139
+ return Response.validated(schema.getToken.Response, {
1140
+ address,
1141
+ // Only the explicitly requested `TokenCreated` extra surfaces: a caller
1142
+ // asking for `quoteToken` alone must not receive `admin`, and vice versa.
1143
+ admin: includeAdmin ? created?.admin : undefined,
1144
+ currency: metadata.currency,
1145
+ createdAt: createdAtByAddress[address],
1146
+ decimals: metadata.decimals,
1147
+ holderCount: holderCount ?? undefined,
1148
+ id: address,
1149
+ // Prefer the curated R2 icon, then the verified entry's curated `logoUri`,
1150
+ // falling back to the precompile's on-chain `logoURI` when present.
1151
+ logoUri: logo ?? snapshot.byAddress.get(address)?.logoUri ?? metadata.logoUri,
1152
+ name: metadata.name,
1153
+ quoteToken: includeQuoteToken ? created?.quoteToken : undefined,
1154
+ symbol: metadata.symbol,
1155
+ totalSupply: metadata.totalSupply,
1156
+ transferStats: stats ?? undefined,
1157
+ verified: snapshot.byAddress.has(address),
1158
+ })
1159
+ }
1160
+
1161
+ export declare namespace resolveToken {
1162
+ type Options = getToken.Options
1163
+ }
1164
+
1165
+ /**
1166
+ * Resolves a token's on-chain metadata (`name`/`symbol`/`decimals`/`currency`/
1167
+ * `totalSupply`) via a single `token.getMetadata` RPC, memoized under the shared
1168
+ * `token:v1:{chainId}:{address}:metadata` key. Exported so feed enrichers (e.g.
1169
+ * `activities`) reuse the same cache entry as the token resource rather than
1170
+ * fetching metadata through a parallel path.
1171
+ */
1172
+ export function getTokenMetadata(c: Context<App.Environment>, options: getTokenMetadata.Options) {
1173
+ const { address, chainId } = options
1174
+ const getClient = c.get('getClient')
1175
+ const store = c.get('store')
1176
+
1177
+ return Store.memoize(
1178
+ async () => {
1179
+ const fresh = await getClient(chainId).token.getMetadata({ token: address as Address })
1180
+ return {
1181
+ currency: fresh.currency,
1182
+ decimals: fresh.decimals,
1183
+ logoUri: fresh.logoURI || undefined,
1184
+ name: fresh.name,
1185
+ symbol: fresh.symbol,
1186
+ totalSupply: fresh.totalSupply.toString(),
1187
+ }
1188
+ },
1189
+ { key: `token:v1:${chainId}:${address}:metadata`, store, ttl: Ttl.minutes(1) },
1190
+ )
1191
+ }
1192
+
1193
+ declare namespace getTokenMetadata {
1194
+ type Options = {
1195
+ address: z.output<typeof Schema.Address>
1196
+ chainId: z.output<typeof Schema.ChainId>
1197
+ }
1198
+ }
1199
+
1200
+ /**
1201
+ * Resolves a token's curated R2 icon URI, memoized per `(chainId, address)`
1202
+ * so page enrichers (balances, holders, transfers `include=token`) don't
1203
+ * re-fetch the asset per row on every request. Misses are memoized as `null`
1204
+ * too — most tokens have no curated icon, and the negative lookup is exactly
1205
+ * what page enrichment would otherwise repeat N times per page.
1206
+ *
1207
+ * The public URI embeds the request origin, so the origin is part of the
1208
+ * cache key; cardinality stays bounded by the number of public hostnames.
1209
+ */
1210
+ export async function getTokenLogo(c: Context<App.Environment>, options: getTokenLogo.Options) {
1211
+ const { address, chainId } = options
1212
+ const getAsset = c.get('getAsset')
1213
+ const store = c.get('store')
1214
+ const origin = new URL(c.req.url).origin
1215
+
1216
+ const uri = await Store.memoize(
1217
+ async () => (await getAsset(chainId, Path.join('icons', address)))?.uri ?? null,
1218
+ { key: `token:v1:${chainId}:${address}:logo:${origin}`, store, ttl: Ttl.minutes(5) },
1219
+ )
1220
+ return uri ?? undefined
1221
+ }
1222
+
1223
+ declare namespace getTokenLogo {
1224
+ type Options = {
1225
+ address: z.output<typeof Schema.Address>
1226
+ chainId: z.output<typeof Schema.ChainId>
1227
+ }
1228
+ }
1229
+
1230
+ /**
1231
+ * Resolves the raw logo image bytes for a token, in priority order:
1232
+ *
1233
+ * 1. The curated R2 icon (`<chainId>/icons/<address>`), our override surface.
1234
+ * 2. The token's on-chain TIP-20 `logoURI` (fetched and proxied), so tokens
1235
+ * that publish a logo on-chain still serve through the API even without a
1236
+ * curated icon.
1237
+ *
1238
+ * Returns `undefined` when neither source yields an image, so the route can
1239
+ * map that to a `404`. The curated R2 icon is trusted (we control it); the
1240
+ * on-chain `logoURI` is deployer-controlled, so it is fetched through the
1241
+ * hardened {@link fetchExternalLogo} (scheme/host allowlist, timeout, size cap,
1242
+ * raster-only content type) to bound the SSRF / resource-exhaustion surface.
1243
+ */
1244
+ async function fetchTokenLogo(
1245
+ c: Context<App.Environment>,
1246
+ options: fetchTokenLogo.Options,
1247
+ ): Promise<fetchTokenLogo.Logo | undefined> {
1248
+ const { address, chainId } = options
1249
+
1250
+ // Curated R2 icon first: our override for tokens with no (or an undesirable)
1251
+ // on-chain logo. Trusted content, served verbatim.
1252
+ const getAsset = c.get('getAsset')
1253
+ const asset = await getAsset(chainId, Path.join('icons', address))
1254
+ if (asset)
1255
+ return {
1256
+ body: await asset.response.arrayBuffer(),
1257
+ contentType: asset.response.headers.get('content-type') ?? 'application/octet-stream',
1258
+ }
1259
+
1260
+ // Fall back to the on-chain TIP-20 `logoURI` (memoized with the rest of the
1261
+ // token metadata). Deployer-controlled, so proxy it defensively.
1262
+ const { logoUri } = await getTokenMetadata(c, { address, chainId })
1263
+ if (!logoUri) return undefined
1264
+ return fetchExternalLogo(logoUri)
1265
+ }
1266
+
1267
+ declare namespace fetchTokenLogo {
1268
+ type Options = {
1269
+ address: z.output<typeof Schema.Address>
1270
+ chainId: z.output<typeof Schema.ChainId>
1271
+ }
1272
+ /** Raw logo image bytes plus the MIME type to serve them with. */
1273
+ type Logo = {
1274
+ body: ArrayBuffer
1275
+ contentType: string
1276
+ }
1277
+ }
1278
+
1279
+ // Cap on proxied logo bytes (1 MiB). A logo larger than this is almost
1280
+ // certainly not a real icon; the cap bounds memory for a hostile upstream.
1281
+ const maxLogoBytes = 1_000_000
1282
+
1283
+ /**
1284
+ * Fetches a deployer-controlled `logoURI` and returns the image bytes, or
1285
+ * `undefined` when the URI is unsafe/unusable. Hardening, since the URI is
1286
+ * attacker-controlled and proxied through our origin:
1287
+ *
1288
+ * - Scheme allowlist (`http`/`https`/`data`); `ipfs://` etc. are unresolvable.
1289
+ * - Blocks loopback/private/link-local hosts to limit SSRF (defense in depth —
1290
+ * Workers `fetch` is not routable to internal infra, but cheap to enforce).
1291
+ * - 3s timeout and a {@link maxLogoBytes} streamed size cap bound resource use.
1292
+ * - Raster-only content type: arbitrary on-chain `image/svg+xml` is rejected
1293
+ * (script-bearing SVG served same-origin is an XSS vector); curated R2 SVGs
1294
+ * are trusted and handled separately.
1295
+ */
1296
+ async function fetchExternalLogo(uri: string): Promise<fetchTokenLogo.Logo | undefined> {
1297
+ const url = (() => {
1298
+ try {
1299
+ return new URL(uri)
1300
+ } catch {
1301
+ return undefined
1302
+ }
1303
+ })()
1304
+ if (!url) return undefined
1305
+ if (url.protocol !== 'http:' && url.protocol !== 'https:' && url.protocol !== 'data:')
1306
+ return undefined
1307
+ if ((url.protocol === 'http:' || url.protocol === 'https:') && isBlockedHost(url.hostname))
1308
+ return undefined
1309
+
1310
+ const response = await fetch(url, {
1311
+ headers: { accept: 'image/png,image/jpeg,image/webp,image/gif,image/avif,image/*;q=0.8' },
1312
+ redirect: 'manual',
1313
+ signal: AbortSignal.timeout(3_000),
1314
+ }).catch(() => undefined)
1315
+ // A non-2xx (incl. an opaque 3xx from `redirect: 'manual'`, so a deployer
1316
+ // cannot bounce us to an internal host) is treated as no logo.
1317
+ if (!response || !response.ok) return undefined
1318
+
1319
+ const contentType = response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase()
1320
+ if (!contentType || !contentType.startsWith('image/') || contentType === 'image/svg+xml')
1321
+ return undefined
1322
+
1323
+ // Reject early when the upstream advertises an oversized body.
1324
+ const length = Number(response.headers.get('content-length'))
1325
+ if (Number.isFinite(length) && length > maxLogoBytes) return undefined
1326
+
1327
+ const body = await readBodyWithLimit(response.body, maxLogoBytes)
1328
+ if (!body) return undefined
1329
+ return { body, contentType }
1330
+ }
1331
+
1332
+ // Reads a stream into an `ArrayBuffer`, returning `undefined` if it exceeds
1333
+ // `limit` (so an upstream without `Content-Length` cannot stream us unbounded
1334
+ // bytes).
1335
+ async function readBodyWithLimit(
1336
+ body: ReadableStream<Uint8Array> | null,
1337
+ limit: number,
1338
+ ): Promise<ArrayBuffer | undefined> {
1339
+ if (!body) return undefined
1340
+ const reader = body.getReader()
1341
+ const chunks: Uint8Array[] = []
1342
+ let total = 0
1343
+ try {
1344
+ for (;;) {
1345
+ const { done, value } = await reader.read()
1346
+ if (done) break
1347
+ total += value.byteLength
1348
+ if (total > limit) return undefined
1349
+ chunks.push(value)
1350
+ }
1351
+ } catch {
1352
+ return undefined
1353
+ } finally {
1354
+ reader.releaseLock()
1355
+ }
1356
+ const buffer = new ArrayBuffer(total)
1357
+ const out = new Uint8Array(buffer)
1358
+ let offset = 0
1359
+ for (const chunk of chunks) {
1360
+ out.set(chunk, offset)
1361
+ offset += chunk.byteLength
1362
+ }
1363
+ return buffer
1364
+ }
1365
+
1366
+ // Blocks hosts that resolve to loopback/private/link-local space, including the
1367
+ // cloud metadata IP. Hostnames that are not literal IPs are allowed (we cannot
1368
+ // resolve DNS here); the `redirect: 'manual'` policy in `fetchExternalLogo`
1369
+ // prevents a public host from bouncing us into private space.
1370
+ function isBlockedHost(hostname: string): boolean {
1371
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, '')
1372
+ if (host === '' || host === 'localhost' || host.endsWith('.localhost')) return true
1373
+ if (host === '::1' || host === '::') return true
1374
+ if (host.startsWith('fc') || host.startsWith('fd') || host.startsWith('fe80:')) return true
1375
+ const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host)
1376
+ if (ipv4) {
1377
+ const a = Number(ipv4[1])
1378
+ const b = Number(ipv4[2])
1379
+ if (a === 0 || a === 10 || a === 127) return true
1380
+ if (a === 169 && b === 254) return true
1381
+ if (a === 172 && b >= 16 && b <= 31) return true
1382
+ if (a === 192 && b === 168) return true
1383
+ if (a === 100 && b >= 64 && b <= 127) return true
1384
+ }
1385
+ return false
1386
+ }
1387
+
1388
+ async function getTokenLogosByAddress(
1389
+ c: Context<App.Environment>,
1390
+ options: getTokenLogosByAddress.Options,
1391
+ ): Promise<Record<string, string>> {
1392
+ const { addresses, chainId } = options
1393
+
1394
+ // Curated R2 icons take precedence (our override for tokens that have no
1395
+ // on-chain logo); resolve them all in parallel.
1396
+ const assets = await Promise.all(
1397
+ addresses.map(
1398
+ async (address) => [address, await getTokenLogo(c, { address, chainId })] as const,
1399
+ ),
1400
+ )
1401
+
1402
+ const logos: Record<string, string> = {}
1403
+ let missing: z.output<typeof Schema.Address>[] = []
1404
+ for (const [address, uri] of assets)
1405
+ if (uri) logos[address] = uri
1406
+ else missing.push(address)
1407
+ if (missing.length === 0) return logos
1408
+
1409
+ // Next, the verified entry's curated `logoUri` (set via the admin API). The
1410
+ // snapshot is primed per isolate, so this is an in-memory map read.
1411
+ const snapshot = await VerifiedTokens.snapshot(c, chainId)
1412
+ missing = missing.filter((address) => {
1413
+ const uri = snapshot.byAddress.get(address)?.logoUri
1414
+ if (uri) logos[address] = uri
1415
+ return !uri
1416
+ })
1417
+ if (missing.length === 0) return logos
1418
+
1419
+ // Fall back to the on-chain `logoURI` for tokens without a curated icon.
1420
+ // Reuse `getTokenMetadata` so the lookup shares the same memoized cache as the
1421
+ // single-token endpoint; the per-token reads batch into a deployless
1422
+ // multicall. Failures degrade to no logo rather than failing the page.
1423
+ const onchain = await Promise.all(
1424
+ missing.map(async (address) => {
1425
+ try {
1426
+ return [address, (await getTokenMetadata(c, { address, chainId })).logoUri] as const
1427
+ } catch {
1428
+ return [address, undefined] as const
1429
+ }
1430
+ }),
1431
+ )
1432
+ for (const [address, uri] of onchain) if (uri) logos[address] = uri
1433
+ return logos
1434
+ }
1435
+
1436
+ declare namespace getTokenLogosByAddress {
1437
+ type Options = {
1438
+ addresses: readonly z.output<typeof Schema.Address>[]
1439
+ chainId: z.output<typeof Schema.ChainId>
1440
+ }
1441
+ }
1442
+
1443
+ function getHolderCount(c: Context<App.Environment>, options: getHolderCount.Options) {
1444
+ const { address, chainId } = options
1445
+ const store = c.get('store')
1446
+ const tidx = c.get('getTidx')(chainId)
1447
+
1448
+ return Store.memoize(
1449
+ async () => {
1450
+ try {
1451
+ // Holder count for the token. Read the pre-aggregated
1452
+ // `token_holder_counts` materialized view (one row per `token`, refreshed
1453
+ // every ~15 min from the positive-balance `token_balances_snapshot`), so
1454
+ // this is a single point lookup on the view's `(token)` sort key rather
1455
+ // than an aggregation over the holder set. `address` is already lowercase
1456
+ // (Schema.Address), matching how the indexer stores the token column. The
1457
+ // inline query is cast to `string` so TIDX treats it as a dynamic
1458
+ // ClickHouse query that needs no event signature for the
1459
+ // `token_holder_counts` table.
1460
+ const result = await tidx.fetch({
1461
+ chainId,
1462
+ engine: 'clickhouse',
1463
+ query: `
1464
+ SELECT holder_count
1465
+ FROM token_holder_counts
1466
+ WHERE token = '${address}'
1467
+ ` as string,
1468
+ })
1469
+ // Persist `null` (not `undefined`) so "no holder data" survives the
1470
+ // cache round-trip without being mistaken for a miss.
1471
+ return Value.toNumber(result.rows[0]?.['holder_count']) ?? null
1472
+ } catch {
1473
+ // Degrade gracefully when holder data is unavailable (e.g. the indexer
1474
+ // lacks the ClickHouse view, or the request is rate limited) so token
1475
+ // metadata still resolves.
1476
+ return null
1477
+ }
1478
+ },
1479
+ { key: `token:v1:${chainId}:${address}:holders`, store, ttl: Ttl.minutes(5) },
1480
+ )
1481
+ }
1482
+
1483
+ declare namespace getHolderCount {
1484
+ type Options = {
1485
+ address: z.output<typeof Schema.Address>
1486
+ chainId: z.output<typeof Schema.ChainId>
1487
+ }
1488
+ }
1489
+
1490
+ async function getHolderCountsByAddress(
1491
+ c: Context<App.Environment>,
1492
+ options: getHolderCountsByAddress.Options,
1493
+ ): Promise<Record<string, number>> {
1494
+ const addresses = Array.from(new Set(options.addresses.map((address) => address.toLowerCase())))
1495
+ .sort()
1496
+ .map((address) => Schema.Address.parse(address))
1497
+ const store = c.get('store')
1498
+ const tidx = c.get('getTidx')(options.chainId)
1499
+
1500
+ if (addresses.length === 0) return {}
1501
+
1502
+ return Store.memoize(
1503
+ async () => {
1504
+ try {
1505
+ // Batched form of `getHolderCount` for a page of tokens: one multi-key
1506
+ // point read of the pre-aggregated `token_holder_counts` materialized
1507
+ // view instead of a `GROUP BY` aggregation over every requested token's
1508
+ // holder set in `token_balances_snapshot` (which, for high-cardinality
1509
+ // tokens, reads millions of rows per group).
1510
+ const result = await tidx.fetch({
1511
+ chainId: options.chainId,
1512
+ engine: 'clickhouse',
1513
+ query: `
1514
+ SELECT token, holder_count
1515
+ FROM token_holder_counts
1516
+ WHERE token IN (${addresses.map((address) => `'${address}'`).join(', ')})
1517
+ ` as string,
1518
+ })
1519
+
1520
+ const holderCountByAddress: Record<string, number> = {}
1521
+ for (const row of result.rows) {
1522
+ const address = Schema.Address.safeParse(row['token'])
1523
+ const holderCount = Value.toNumber(row['holder_count'])
1524
+ if (address.success && holderCount !== undefined)
1525
+ holderCountByAddress[address.data] = holderCount
1526
+ }
1527
+ return holderCountByAddress
1528
+ } catch {
1529
+ // Degrade gracefully so the token page still resolves without counts.
1530
+ return {}
1531
+ }
1532
+ },
1533
+ {
1534
+ key: `token:v1:${options.chainId}:holders:${addresses.join(',')}`,
1535
+ store,
1536
+ ttl: Ttl.minutes(5),
1537
+ },
1538
+ )
1539
+ }
1540
+
1541
+ declare namespace getHolderCountsByAddress {
1542
+ type Options = {
1543
+ addresses: readonly z.output<typeof Schema.Address>[]
1544
+ chainId: z.output<typeof Schema.ChainId>
1545
+ }
1546
+ }
1547
+
1548
+ function getTransferStats(c: Context<App.Environment>, options: getTransferStats.Options) {
1549
+ const { address, chainId } = options
1550
+ const store = c.get('store')
1551
+ const tidx = c.get('getTidx')(chainId)
1552
+
1553
+ return Store.memoize<getTransferStats.Stats | null>(
1554
+ async () => {
1555
+ try {
1556
+ // Lifetime `Transfer` statistics for the token, aggregated over the
1557
+ // raw `logs` event stream by emitting contract. The inline query is
1558
+ // cast to `string` so TIDX treats it as a dynamic ClickHouse query
1559
+ // that needs no event signature for the `logs` table.
1560
+ const result = await tidx.fetch({
1561
+ chainId,
1562
+ engine: 'clickhouse',
1563
+ query: `
1564
+ SELECT count() AS transfer_count, min(block_timestamp) AS first_at, max(block_timestamp) AS last_at
1565
+ FROM logs
1566
+ WHERE address = '${address}' AND selector = '${transferTopic}'
1567
+ ` as string,
1568
+ })
1569
+ const row = result.rows[0]
1570
+ // Persist `null` (not `undefined`) so "no transfer data" survives the
1571
+ // cache round-trip without being mistaken for a miss.
1572
+ const count = Value.toNumber(row?.['transfer_count'])
1573
+ if (count === undefined) return null
1574
+ // A zero count comes back with ClickHouse epoch-default (1970)
1575
+ // `min`/`max` aggregates; report explicit nulls instead.
1576
+ if (count === 0) return { count: 0, firstAt: null, lastAt: null }
1577
+ // ClickHouse formats timestamps as naive-UTC `YYYY-MM-DD HH:MM:SS.sss`;
1578
+ // `Value.toIsoDateTime` normalizes them to strict ISO 8601 UTC.
1579
+ return {
1580
+ count,
1581
+ firstAt: Value.toIsoDateTime(row?.['first_at']) ?? null,
1582
+ lastAt: Value.toIsoDateTime(row?.['last_at']) ?? null,
1583
+ }
1584
+ } catch {
1585
+ // Degrade gracefully when transfer data is unavailable (e.g. the
1586
+ // indexer cannot answer the aggregation, or the request is rate
1587
+ // limited) so token metadata still resolves.
1588
+ return null
1589
+ }
1590
+ },
1591
+ { key: `token:v1:${chainId}:${address}:transfer_stats`, store, ttl: Ttl.minutes(5) },
1592
+ )
1593
+ }
1594
+
1595
+ declare namespace getTransferStats {
1596
+ /** Lifetime `Transfer` statistics for one token, as surfaced on the token resource. */
1597
+ type Stats = Exclude<z.output<typeof schema.getToken.Response>['transferStats'], undefined>
1598
+ type Options = {
1599
+ address: z.output<typeof Schema.Address>
1600
+ chainId: z.output<typeof Schema.ChainId>
1601
+ }
1602
+ }
1603
+
1604
+ async function getTransferStatsByAddress(
1605
+ c: Context<App.Environment>,
1606
+ options: getTransferStatsByAddress.Options,
1607
+ ): Promise<Record<string, getTransferStats.Stats>> {
1608
+ const addresses = Array.from(new Set(options.addresses.map((address) => address.toLowerCase())))
1609
+ .sort()
1610
+ .map((address) => Schema.Address.parse(address))
1611
+ const store = c.get('store')
1612
+ const tidx = c.get('getTidx')(options.chainId)
1613
+
1614
+ if (addresses.length === 0) return {}
1615
+
1616
+ return Store.memoize(
1617
+ async () => {
1618
+ try {
1619
+ // Batched form of `getTransferStats` for a page of tokens: one grouped
1620
+ // aggregation over the raw `logs` stream instead of N per-token scans.
1621
+ // Tokens with no `Transfer` events emit no group and are omitted.
1622
+ const result = await tidx.fetch({
1623
+ chainId: options.chainId,
1624
+ engine: 'clickhouse',
1625
+ query: `
1626
+ SELECT address, count() AS transfer_count, min(block_timestamp) AS first_at, max(block_timestamp) AS last_at
1627
+ FROM logs
1628
+ WHERE selector = '${transferTopic}' AND address IN (${addresses.map((address) => `'${address}'`).join(', ')})
1629
+ GROUP BY address
1630
+ ` as string,
1631
+ })
1632
+
1633
+ const statsByAddress: Record<string, getTransferStats.Stats> = {}
1634
+ for (const row of result.rows) {
1635
+ const address = Schema.Address.safeParse(row['address'])
1636
+ const count = Value.toNumber(row['transfer_count'])
1637
+ if (!address.success || count === undefined) continue
1638
+ statsByAddress[address.data] = {
1639
+ count,
1640
+ firstAt: Value.toIsoDateTime(row['first_at']) ?? null,
1641
+ lastAt: Value.toIsoDateTime(row['last_at']) ?? null,
1642
+ }
1643
+ }
1644
+ return statsByAddress
1645
+ } catch {
1646
+ // Degrade gracefully so the token page still resolves without stats.
1647
+ return {}
1648
+ }
1649
+ },
1650
+ {
1651
+ key: `token:v1:${options.chainId}:transfer_stats:${addresses.join(',')}`,
1652
+ store,
1653
+ ttl: Ttl.minutes(5),
1654
+ },
1655
+ )
1656
+ }
1657
+
1658
+ declare namespace getTransferStatsByAddress {
1659
+ type Options = {
1660
+ addresses: readonly z.output<typeof Schema.Address>[]
1661
+ chainId: z.output<typeof Schema.ChainId>
1662
+ }
1663
+ }
1664
+
1665
+ function getTokenHolders(c: Context<App.Environment>, options: getTokenHolders.Options) {
1666
+ const { address, chainId, limit } = options
1667
+ const store = c.get('store')
1668
+ const tidx = c.get('getTidx')(chainId)
1669
+ // Keyset pagination on `(balance, holder)`: balance is descending and `holder`
1670
+ // is a stable tiebreaker for the many holders sharing a balance. A malformed
1671
+ // cursor falls back to the head page. Note: because `balance` is mutable, this
1672
+ // is best-effort across balance changes (the inherent limit of ranking feeds).
1673
+ const cursor = options.cursor ? Cursor.decode(options.cursor, ['uint', 'address']) : undefined
1674
+ // Bounded positional lane (exclusive with `cursor` at the schema): page 1 is
1675
+ // the head page, so it shares the head's SQL and cache entries; deeper pages
1676
+ // translate to a SQL OFFSET of `(page - 1) * limit` rows.
1677
+ const offset =
1678
+ options.page !== undefined && options.page > 1 ? (options.page - 1) * limit : undefined
1679
+
1680
+ return Timing.time(c, 'token_holders', () =>
1681
+ Store.memoize(
1682
+ async () => {
1683
+ // A holder is an address with a positive balance of the token. Read the
1684
+ // pre-aggregated, `FINAL`-correct `token_balances_snapshot` materialized
1685
+ // view (one row per `(token, holder, balance)`, ordered by
1686
+ // `(token, balance)`), so this is a primary-key read that stays under the
1687
+ // indexer's ClickHouse budget even for high-cardinality tokens (e.g.
1688
+ // PathUSD). Fetch one extra row to detect `hasMore` without a separate
1689
+ // count query. The inline query is cast to `string` so TIDX treats it as
1690
+ // a dynamic ClickHouse query that needs no event signature for the
1691
+ // `token_balances_snapshot` table.
1692
+ //
1693
+ // Let upstream failures propagate: unlike `getHolderCount` (optional
1694
+ // enrichment that degrades to `null`), this is a dedicated endpoint, so a
1695
+ // failure should surface as the declared `502` rather than masquerade as
1696
+ // a token with zero holders.
1697
+ const keyset =
1698
+ cursor !== undefined
1699
+ ? ` AND ${Cursor.keyset([
1700
+ { literal: Cursor.literal(cursor[0]!, 'uint'), name: 'balance', order: 'desc' },
1701
+ { literal: Cursor.literal(cursor[1]!, 'address'), name: 'holder', order: 'asc' },
1702
+ ])}`
1703
+ : ''
1704
+
1705
+ // `balance` is UInt256; SELECT it as a string so values > 2^53 survive
1706
+ // JSON without precision loss. The keyset cursor encodes the row's
1707
+ // exact balance, so any rounding would re-include the cursor row on
1708
+ // the next page. Alias is `balance_str` (not `balance`) because TIDX
1709
+ // rejects expression aliases that shadow the source column (422).
1710
+ const result = await tidx.fetch({
1711
+ chainId,
1712
+ engine: 'clickhouse',
1713
+ query: `
1714
+ SELECT holder, toString(balance) AS balance_str
1715
+ FROM token_balances_snapshot
1716
+ WHERE token = '${address}' AND balance > 0${keyset}
1717
+ ORDER BY balance DESC, holder ASC
1718
+ LIMIT ${limit + 1}${offset !== undefined ? ` OFFSET ${offset}` : ''}
1719
+ ` as string,
1720
+ })
1721
+
1722
+ // The next page anchors below the last fetched row's `(balance, holder)`.
1723
+ const page = Cursor.paginate({
1724
+ rows: result.rows,
1725
+ limit,
1726
+ key: (row) => {
1727
+ const balance = Value.toIntegerString(row['balance_str'])
1728
+ const holder = Schema.Address.safeParse(row['holder'])
1729
+ return balance !== undefined && holder.success ? [balance, holder.data] : undefined
1730
+ },
1731
+ })
1732
+
1733
+ const data: { address: string; balance: string; id: string }[] = []
1734
+ for (const row of page.rows) {
1735
+ const holder = Schema.Address.safeParse(row['holder'])
1736
+ const balance = Value.toIntegerString(row['balance_str'])
1737
+ if (holder.success && balance !== undefined)
1738
+ data.push({ address: holder.data, balance, id: holder.data })
1739
+ }
1740
+
1741
+ return { data, nextCursor: page.nextCursor }
1742
+ },
1743
+ {
1744
+ key: `token:v1:${chainId}:${address}:holders:${cursor ? `cursor:${cursor[0]}:${cursor[1]}` : offset !== undefined ? `page:${options.page}` : 'head'}:${limit}`,
1745
+ store,
1746
+ ttl: Ttl.minutes(1),
1747
+ },
1748
+ ),
1749
+ )
1750
+ }
1751
+
1752
+ declare namespace getTokenHolders {
1753
+ type Options = {
1754
+ address: z.output<typeof Schema.Address>
1755
+ chainId: z.output<typeof Schema.ChainId>
1756
+ /** Opaque keyset cursor anchoring the page; omit for the head page. */
1757
+ cursor?: string | undefined
1758
+ limit: number
1759
+ /** 1-indexed page number (positional pagination; exclusive with `cursor`). */
1760
+ page?: number | undefined
1761
+ }
1762
+ }
1763
+
1764
+ /**
1765
+ * Resolves `createdAt` for many tokens in one TIDX round-trip while keeping
1766
+ * cache entries keyed per-address, so two pages sharing N-1 of N tokens
1767
+ * naturally reuse most of the cache. We:
1768
+ *
1769
+ * 1. Look up each requested address in the per-address cache.
1770
+ * 2. Issue a single `IN (…)` query for the misses only.
1771
+ * 3. Write each freshly-resolved address back to the per-address cache.
1772
+ *
1773
+ * Token creation timestamps are immutable, so successful entries are cached
1774
+ * for a day. Addresses the indexer answered but did not resolve (no
1775
+ * `TokenCreated` row — e.g. genesis tokens) are negative-cached briefly so
1776
+ * they do not re-run the `logs` decode CTE on every request; the short TTL
1777
+ * covers tokens that are simply not indexed yet.
1778
+ */
1779
+ export async function getTokenCreatedAtByAddress(
1780
+ c: Context<App.Environment>,
1781
+ options: getTokenCreatedAtByAddress.Options,
1782
+ ): Promise<Record<string, string>> {
1783
+ const addresses = Array.from(new Set(options.addresses.map((address) => address.toLowerCase())))
1784
+ .sort()
1785
+ .map((address) => Schema.Address.parse(address))
1786
+ const store = c.get('store')
1787
+ const tidx = c.get('getTidx')(options.chainId)
1788
+
1789
+ if (addresses.length === 0) return {}
1790
+
1791
+ const key = (address: string) => `token:v1:${options.chainId}:${address}:created_at`
1792
+
1793
+ const cached = await Promise.all(addresses.map((address) => store.get(key(address))))
1794
+ const createdAtByAddress: Record<string, string> = {}
1795
+ const misses: string[] = []
1796
+ for (const [index, address] of addresses.entries()) {
1797
+ const hit = cached[index]
1798
+ // A negative entry means "known to have no TokenCreated row" — resolved
1799
+ // as absent, so it neither joins the result nor re-queries.
1800
+ if (hit === noCreatedAt) continue
1801
+ if (hit) createdAtByAddress[address] = hit
1802
+ else misses.push(address)
1803
+ }
1804
+ if (misses.length === 0) return createdAtByAddress
1805
+
1806
+ try {
1807
+ const result = await tidx.fetch({
1808
+ chainId: options.chainId,
1809
+ query: `
1810
+ SELECT token, block_timestamp
1811
+ FROM tokencreated
1812
+ WHERE token IN (${misses.map((address) => `'${address}'`).join(', ')})
1813
+ `,
1814
+ signatures: [tokenCreatedSignature],
1815
+ })
1816
+
1817
+ const writes: Promise<unknown>[] = []
1818
+ for (const row of result.rows) {
1819
+ const address = Schema.Address.safeParse(row.token)
1820
+ const createdAt = Value.toIsoDateTime(row.block_timestamp)
1821
+ if (!address.success || !createdAt) continue
1822
+ createdAtByAddress[address.data] = createdAt
1823
+ writes.push(store.put(key(address.data), createdAt, { ttl: Ttl.days(1) }))
1824
+ }
1825
+ // Negative-cache the addresses this (successful) query did not resolve, so
1826
+ // a token with no `TokenCreated` row doesn't re-run the decode CTE on
1827
+ // every request.
1828
+ for (const address of misses)
1829
+ if (!(address in createdAtByAddress))
1830
+ writes.push(store.put(key(address), noCreatedAt, { ttl: Ttl.minutes(1) }))
1831
+ // Persist new entries best-effort; do not block the response on cache writes.
1832
+ void Promise.all(writes).catch(() => {})
1833
+ } catch {
1834
+ // Creation timestamps improve resource completeness, but token metadata
1835
+ // should remain available when the indexer is temporarily unable to
1836
+ // answer this auxiliary query.
1837
+ }
1838
+ return createdAtByAddress
1839
+ }
1840
+
1841
+ /**
1842
+ * Negative-cache sentinel for {@link getTokenCreatedAtByAddress}: stored under
1843
+ * an address's `created_at` key when the indexer has no `TokenCreated` row
1844
+ * for it, distinguishable from any real ISO timestamp.
1845
+ */
1846
+ const noCreatedAt = 'none'
1847
+
1848
+ export declare namespace getTokenCreatedAtByAddress {
1849
+ type Options = {
1850
+ addresses: readonly z.output<typeof Schema.Address>[]
1851
+ chainId: z.output<typeof Schema.ChainId>
1852
+ }
1853
+ }
1854
+
1855
+ /**
1856
+ * Resolves the `TokenCreated` extras (`admin`/`quoteToken`, plus the event's
1857
+ * `createdAt` timestamp) for many tokens in one TIDX round-trip while keeping
1858
+ * cache entries keyed per-address, mirroring {@link getTokenCreatedAtByAddress}.
1859
+ * The deployed indexer's `tokencreated` event CTE cannot serve the
1860
+ * `quoteToken`/`admin` columns (selecting them returns `db error`), so this
1861
+ * reads the raw `logs` table by topic0 and ABI-decodes the non-indexed event
1862
+ * data instead.
1863
+ *
1864
+ * Token creation payloads are immutable, so successful entries are cached for
1865
+ * a day. Addresses the indexer answered but did not resolve (no `TokenCreated`
1866
+ * log — e.g. genesis tokens) are negative-cached briefly so they do not
1867
+ * re-scan `logs` on every request; the short TTL covers tokens that are simply
1868
+ * not indexed yet.
1869
+ */
1870
+ async function getTokenCreatedByAddress(
1871
+ c: Context<App.Environment>,
1872
+ options: getTokenCreatedByAddress.Options,
1873
+ ): Promise<Record<string, getTokenCreatedByAddress.Created>> {
1874
+ const addresses = Array.from(new Set(options.addresses.map((address) => address.toLowerCase())))
1875
+ .sort()
1876
+ .map((address) => Schema.Address.parse(address))
1877
+ const store = c.get('store')
1878
+ const tidx = c.get('getTidx')(options.chainId)
1879
+
1880
+ if (addresses.length === 0) return {}
1881
+
1882
+ const key = (address: string) => `token:v2:${options.chainId}:${address}:created`
1883
+
1884
+ const cached = await Promise.all(addresses.map((address) => store.get(key(address))))
1885
+ const createdByAddress: Record<string, getTokenCreatedByAddress.Created> = {}
1886
+ const misses: string[] = []
1887
+ for (const [index, address] of addresses.entries()) {
1888
+ const hit = cached[index]
1889
+ // A negative entry means "known to have no TokenCreated log" — resolved
1890
+ // as absent, so it neither joins the result nor re-queries.
1891
+ if (hit === noCreated) continue
1892
+ if (hit) createdByAddress[address] = JSON.parse(hit) as getTokenCreatedByAddress.Created
1893
+ else misses.push(address)
1894
+ }
1895
+ if (misses.length === 0) return createdByAddress
1896
+
1897
+ try {
1898
+ // The raw `logs` table stores topic0 in the `selector` column; `topic1` is
1899
+ // the indexed `token` address left-padded to a 32-byte topic. The inline
1900
+ // query is cast to `string` so TIDX treats it as a dynamic query that
1901
+ // needs no event signature for the `logs` table.
1902
+ const result = await tidx.fetch({
1903
+ chainId: options.chainId,
1904
+ query: `
1905
+ SELECT topic1, data, block_timestamp
1906
+ FROM logs
1907
+ WHERE selector = '${tokenCreatedTopic}'
1908
+ AND topic1 IN (${misses.map((address) => `'0x${'0'.repeat(24)}${address.slice(2)}'`).join(', ')})
1909
+ ` as string,
1910
+ })
1911
+
1912
+ const writes: Promise<unknown>[] = []
1913
+ for (const row of result.rows) {
1914
+ const topic = Value.toText(row['topic1'])
1915
+ const data = Schema.Hex.safeParse(row['data'])
1916
+ const createdAt = Value.toIsoDateTime(row['block_timestamp'])
1917
+ if (!topic || !data.success || !createdAt) continue
1918
+ // `topic1` left-pads the address to 32 bytes; strip the padding.
1919
+ const address = Schema.Address.safeParse(`0x${topic.slice(-40)}`)
1920
+ if (!address.success) continue
1921
+ const decoded = (() => {
1922
+ try {
1923
+ return AbiParameters.decode(tokenCreatedData, data.data)
1924
+ } catch {
1925
+ return undefined
1926
+ }
1927
+ })()
1928
+ if (!decoded) continue
1929
+ const quoteToken = Schema.Address.safeParse(decoded[3])
1930
+ const admin = Schema.Address.safeParse(decoded[4])
1931
+ if (!quoteToken.success || !admin.success) continue
1932
+ const created = { admin: admin.data, createdAt, quoteToken: quoteToken.data }
1933
+ createdByAddress[address.data] = created
1934
+ writes.push(store.put(key(address.data), JSON.stringify(created), { ttl: Ttl.days(1) }))
1935
+ }
1936
+ // Negative-cache the addresses this (successful) query did not resolve, so
1937
+ // a token with no `TokenCreated` log doesn't re-scan `logs` on every
1938
+ // request.
1939
+ for (const address of misses)
1940
+ if (!(address in createdByAddress))
1941
+ writes.push(store.put(key(address), noCreated, { ttl: Ttl.minutes(1) }))
1942
+ // Persist new entries best-effort; do not block the response on cache writes.
1943
+ void Promise.all(writes).catch(() => {})
1944
+ } catch {
1945
+ // `TokenCreated` extras improve resource completeness, but token metadata
1946
+ // should remain available when the indexer is temporarily unable to
1947
+ // answer this auxiliary query.
1948
+ }
1949
+ return createdByAddress
1950
+ }
1951
+
1952
+ /**
1953
+ * Negative-cache sentinel for {@link getTokenCreatedByAddress}: stored under
1954
+ * an address's `created` key when the indexer has no `TokenCreated` log for
1955
+ * it, distinguishable from any real JSON payload.
1956
+ */
1957
+ const noCreated = 'none'
1958
+
1959
+ declare namespace getTokenCreatedByAddress {
1960
+ /** `TokenCreated` payload fields resolved per token. */
1961
+ type Created = {
1962
+ /** Token admin address from the `TokenCreated` event. */
1963
+ admin: z.output<typeof Schema.Address>
1964
+ /** Token creation timestamp (ISO 8601). */
1965
+ createdAt: string
1966
+ /** Quote token address from the `TokenCreated` event. */
1967
+ quoteToken: z.output<typeof Schema.Address>
1968
+ }
1969
+ type Options = {
1970
+ addresses: readonly z.output<typeof Schema.Address>[]
1971
+ chainId: z.output<typeof Schema.ChainId>
1972
+ }
1973
+ }
1974
+
1975
+ async function getTokens(c: Context<App.Environment>, options: getTokens.Options) {
1976
+ const store = c.get('store')
1977
+ const tidx = c.get('getTidx')(options.chainId)
1978
+ const snapshot = await VerifiedTokens.snapshot(c, options.chainId)
1979
+
1980
+ // `verified=true` is served from the curated static list — TIDX's event-CTE
1981
+ // planner rejects `WHERE token IN (…)` against `tokencreated` when combined
1982
+ // with `ORDER BY` / `LIMIT`, so we paginate the in-memory list positionally
1983
+ // instead (see the branch below). The response keeps the same shape as the
1984
+ // unfiltered page: `currency` narrows the static rows, the `include`
1985
+ // enrichments run against the page's addresses, and `cursor`/`page`/`limit`/
1986
+ // `order` slice the list.
1987
+ // `createdAt` is an opt-in `include`: skip the indexer round-trip unless the
1988
+ // caller asked for it, keeping the base page fast.
1989
+ const wantCreatedAt = options.include?.includes('createdAt') ?? false
1990
+ // `admin` and `quoteToken` both decode from the raw `TokenCreated` log, so
1991
+ // one batched fetch serves either; each field still only surfaces when its
1992
+ // own include was requested. Holder counts and transfer stats are likewise
1993
+ // opt-in batched enrichments.
1994
+ const wantAdmin = options.include?.includes('admin') ?? false
1995
+ const wantQuoteToken = options.include?.includes('quoteToken') ?? false
1996
+ const wantCreatedExtras = wantAdmin || wantQuoteToken
1997
+ const wantHolderCount = options.include?.includes('holderCount') ?? false
1998
+ const wantTransferStats = options.include?.includes('transferStats') ?? false
1999
+
2000
+ // An explicit `addresses=` batch lookup takes precedence over both the
2001
+ // verified-list branch and the indexed listing: the caller already named the
2002
+ // exact tokens, so `cursor`/`order` are inapplicable and the page is the
2003
+ // resolved set in input order. Batch enrichments are pre-fetched here (one
2004
+ // TIDX query per include) and passed through `resolveToken`, mirroring the
2005
+ // verified branch, so the per-address fan-out costs RPC metadata only.
2006
+ if (options.addresses !== undefined) {
2007
+ const addresses = options.addresses
2008
+ const [createdAtByAddress, createdByAddress, holderCountByAddress, transferStatsByAddress] =
2009
+ await Promise.all([
2010
+ wantCreatedAt && addresses.length > 0
2011
+ ? Timing.time(c, 'tokens_created_at', () =>
2012
+ getTokenCreatedAtByAddress(c, { addresses, chainId: options.chainId }),
2013
+ )
2014
+ : Promise.resolve({} as Record<string, string>),
2015
+ wantCreatedExtras && addresses.length > 0
2016
+ ? Timing.time(c, 'tokens_created_extras', () =>
2017
+ getTokenCreatedByAddress(c, { addresses, chainId: options.chainId }),
2018
+ )
2019
+ : Promise.resolve({} as Record<string, getTokenCreatedByAddress.Created>),
2020
+ wantHolderCount && addresses.length > 0
2021
+ ? Timing.time(c, 'tokens_holders', () =>
2022
+ getHolderCountsByAddress(c, { addresses, chainId: options.chainId }),
2023
+ )
2024
+ : Promise.resolve(undefined),
2025
+ wantTransferStats && addresses.length > 0
2026
+ ? Timing.time(c, 'tokens_transfers', () =>
2027
+ getTransferStatsByAddress(c, { addresses, chainId: options.chainId }),
2028
+ )
2029
+ : Promise.resolve(undefined),
2030
+ ])
2031
+
2032
+ // Unresolvable addresses (not a TIP-20 token, upstream failure) drop out
2033
+ // of the page rather than failing it; input order is preserved.
2034
+ const resolved = await Promise.all(
2035
+ addresses.map((address) =>
2036
+ resolveToken(c, {
2037
+ address,
2038
+ chainId: options.chainId,
2039
+ createdAtByAddress,
2040
+ createdByAddress,
2041
+ holderCountByAddress,
2042
+ include: options.include,
2043
+ snapshot,
2044
+ transferStatsByAddress,
2045
+ }).catch(() => undefined),
2046
+ ),
2047
+ )
2048
+
2049
+ // `currency` and `verified=true` act as post-filters on the resolved set:
2050
+ // the caller addressed specific tokens, so non-matching rows drop out
2051
+ // instead of erroring.
2052
+ const currency = options.currency?.toLowerCase()
2053
+ return {
2054
+ data: resolved.filter(
2055
+ (token): token is Exclude<typeof token, undefined> =>
2056
+ token !== undefined &&
2057
+ (currency === undefined || token.currency.toLowerCase() === currency) &&
2058
+ (options.verified !== true || token.verified),
2059
+ ),
2060
+ nextCursor: null,
2061
+ }
2062
+ }
2063
+
2064
+ if (options.verified) {
2065
+ const all = options.currency
2066
+ ? (snapshot.byCurrency.get(options.currency.toLowerCase()) ?? [])
2067
+ : snapshot.list
2068
+ // The curated list is static and fully in memory, so paginate it
2069
+ // positionally: the cursor encodes the next offset, `page` slices by index,
2070
+ // and `order=asc` reverses the canonical (`desc`) order. Only the page slice
2071
+ // is enriched below, so a request costs at most `limit` logo/stat lookups
2072
+ // instead of one per verified token.
2073
+ const ordered = options.order === 'asc' ? [...all].reverse() : all
2074
+ const start =
2075
+ (Cursor.decode(options.cursor ?? '', ['int'])?.[0] as number | undefined) ??
2076
+ (options.page !== undefined && options.page > 1 ? (options.page - 1) * options.limit : 0)
2077
+ const rows = ordered.slice(start, start + options.limit)
2078
+ const nextCursor =
2079
+ ordered.length > start + options.limit ? Cursor.encode([start + options.limit]) : null
2080
+ // `createdAt`, the `TokenCreated` extras, holder counts, and transfer
2081
+ // stats come from TIDX. We fetch them in parallel so the verified page
2082
+ // costs at most one round-trip per enrichment — and each is only fetched
2083
+ // when opted into via `include`.
2084
+ const addresses = rows.map((token) => token.address)
2085
+ const [createdAtByAddress, createdByAddress, holderCounts, logoByAddress, statsByAddress] =
2086
+ await Promise.all([
2087
+ wantCreatedAt && rows.length > 0
2088
+ ? Timing.time(c, 'tokens_created_at', () =>
2089
+ getTokenCreatedAtByAddress(c, { addresses, chainId: options.chainId }),
2090
+ )
2091
+ : Promise.resolve({} as Record<string, string>),
2092
+ wantCreatedExtras && rows.length > 0
2093
+ ? Timing.time(c, 'tokens_created_extras', () =>
2094
+ getTokenCreatedByAddress(c, { addresses, chainId: options.chainId }),
2095
+ )
2096
+ : Promise.resolve({} as Record<string, getTokenCreatedByAddress.Created>),
2097
+ wantHolderCount && rows.length > 0
2098
+ ? Timing.time(c, 'tokens_holders', () =>
2099
+ getHolderCountsByAddress(c, { addresses, chainId: options.chainId }),
2100
+ )
2101
+ : Promise.resolve(undefined),
2102
+ // Logos always ride along on the verified page: the curated set is small
2103
+ // and the page is cached. Curated R2 icons take precedence, falling back
2104
+ // to the on-chain `logoURI` for any token without one.
2105
+ Timing.time(c, 'tokens_logos', () =>
2106
+ getTokenLogosByAddress(c, { addresses, chainId: options.chainId }),
2107
+ ),
2108
+ wantTransferStats && rows.length > 0
2109
+ ? Timing.time(c, 'tokens_transfers', () =>
2110
+ getTransferStatsByAddress(c, { addresses, chainId: options.chainId }),
2111
+ )
2112
+ : Promise.resolve(undefined),
2113
+ ])
2114
+ return {
2115
+ data: rows.map((token) => {
2116
+ const created = createdByAddress[token.address]
2117
+ return {
2118
+ address: token.address,
2119
+ admin: wantAdmin ? created?.admin : undefined,
2120
+ currency: token.currency,
2121
+ createdAt: createdAtByAddress[token.address],
2122
+ decimals: token.decimals,
2123
+ holderCount: holderCounts?.[token.address],
2124
+ id: token.address,
2125
+ logoUri: logoByAddress[token.address],
2126
+ name: token.name,
2127
+ quoteToken: wantQuoteToken ? created?.quoteToken : undefined,
2128
+ symbol: token.symbol,
2129
+ transferStats: statsByAddress?.[token.address],
2130
+ verified: true,
2131
+ }
2132
+ }),
2133
+ nextCursor,
2134
+ }
2135
+ }
2136
+
2137
+ const limit = options.limit
2138
+ const order = options.order
2139
+ const direction = order === 'asc' ? 'ASC' : 'DESC'
2140
+ // Keyset pagination on `(block_num, log_idx)`: anchor the page below the
2141
+ // previous row's creation position instead of a numeric offset, so newly
2142
+ // created tokens can't shift items across pages. A malformed cursor falls back
2143
+ // to the head page.
2144
+ const cursor = options.cursor ? Cursor.decode(options.cursor, ['int', 'int']) : undefined
2145
+ // Bounded positional lane (exclusive with `cursor` at the schema): page 1 is
2146
+ // the head page, so it shares the head's SQL and cache entries; deeper pages
2147
+ // translate to a SQL OFFSET of `(page - 1) * limit` rows.
2148
+ const offset =
2149
+ options.page !== undefined && options.page > 1 ? (options.page - 1) * limit : undefined
2150
+
2151
+ function getIndexedTokens() {
2152
+ return Store.memoize(
2153
+ async () => {
2154
+ // TIDX's event-CTE planner refuses `WHERE token = X` / `token IN (…)`
2155
+ // on `tokencreated` when combined with `ORDER BY` / `LIMIT` (column
2156
+ // equality on event params errors with `db error`). Supported filters
2157
+ // here are real columns like `block_num` and `currency`, plus the
2158
+ // cursor keyset; verified-only mode is handled above by serving the
2159
+ // curated static list directly.
2160
+ const filters: string[] = []
2161
+ if (cursor !== undefined)
2162
+ filters.push(
2163
+ Cursor.keyset([
2164
+ { literal: Cursor.literal(cursor[0]!, 'int'), name: 'block_num', order },
2165
+ { literal: Cursor.literal(cursor[1]!, 'int'), name: 'log_idx', order },
2166
+ ]),
2167
+ )
2168
+ if (options.currency !== undefined)
2169
+ filters.push(`LOWER(currency) = LOWER('${Tidx.escape(options.currency)}')`)
2170
+ const where = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''
2171
+
2172
+ const result = await tidx.fetch({
2173
+ chainId: options.chainId,
2174
+ query: `
2175
+ SELECT token, block_num, log_idx, block_timestamp, currency, name, symbol
2176
+ FROM tokencreated
2177
+ ${where}
2178
+ ORDER BY block_num ${direction}, log_idx ${direction}
2179
+ LIMIT ${limit + 1}${offset !== undefined ? ` OFFSET ${offset}` : ''}
2180
+ `,
2181
+ signatures: [tokenCreatedSignature],
2182
+ })
2183
+ // The next page anchors below the last fetched row's `(block, log_idx)`.
2184
+ const page = Cursor.paginate({
2185
+ rows: result.rows,
2186
+ limit,
2187
+ key: (row) => {
2188
+ const block = Value.toNumber(row.block_num)
2189
+ const index = Value.toNumber(row.log_idx)
2190
+ return block !== undefined && index !== undefined ? [block, index] : undefined
2191
+ },
2192
+ })
2193
+ return {
2194
+ nextCursor: page.nextCursor,
2195
+ rows: page.rows.map((row) => ({
2196
+ currency: row.currency,
2197
+ createdAt: Value.toIsoDateTime(row.block_timestamp),
2198
+ name: row.name,
2199
+ symbol: row.symbol,
2200
+ token: row.token,
2201
+ })),
2202
+ }
2203
+ },
2204
+ {
2205
+ key: `token:v1:${options.chainId}:indexed:${order}:${encodeURIComponent(options.currency?.toLowerCase() ?? '')}:${cursor ? `cursor:${cursor[0]}:${cursor[1]}` : offset !== undefined ? `page:${options.page}` : 'head'}:${limit}`,
2206
+ store,
2207
+ ttl: Ttl.minutes(2),
2208
+ },
2209
+ )
2210
+ }
2211
+
2212
+ const chunk = await Timing.time(c, 'tokens_indexed', () => getIndexedTokens())
2213
+ const addresses = chunk.rows.map((row) => row.token)
2214
+
2215
+ // `TokenCreated` extras, holder counts, and transfer stats are opt-in
2216
+ // `include`s; logos always ride along (curated R2 icon, falling back to the
2217
+ // on-chain `logoURI`). Resolve all in parallel.
2218
+ const [createdByAddress, holderCounts, logoByAddress, statsByAddress] = await Promise.all([
2219
+ wantCreatedExtras && chunk.rows.length > 0
2220
+ ? Timing.time(c, 'tokens_created_extras', () =>
2221
+ getTokenCreatedByAddress(c, { addresses, chainId: options.chainId }),
2222
+ )
2223
+ : Promise.resolve({} as Record<string, getTokenCreatedByAddress.Created>),
2224
+ wantHolderCount && chunk.rows.length > 0
2225
+ ? Timing.time(c, 'tokens_holders', () =>
2226
+ getHolderCountsByAddress(c, { addresses, chainId: options.chainId }),
2227
+ )
2228
+ : Promise.resolve(undefined),
2229
+ chunk.rows.length > 0
2230
+ ? Timing.time(c, 'tokens_logos', () =>
2231
+ getTokenLogosByAddress(c, { addresses, chainId: options.chainId }),
2232
+ )
2233
+ : Promise.resolve({} as Record<string, string>),
2234
+ wantTransferStats && chunk.rows.length > 0
2235
+ ? Timing.time(c, 'tokens_transfers', () =>
2236
+ getTransferStatsByAddress(c, { addresses, chainId: options.chainId }),
2237
+ )
2238
+ : Promise.resolve(undefined),
2239
+ ])
2240
+
2241
+ return {
2242
+ data: chunk.rows.map((row) => {
2243
+ const created = createdByAddress[row.token]
2244
+ return {
2245
+ address: row.token,
2246
+ admin: wantAdmin ? created?.admin : undefined,
2247
+ currency: row.currency,
2248
+ // `createdAt` rides along free in the indexed page query, but stays an
2249
+ // opt-in field for a consistent contract with the verified page.
2250
+ createdAt: wantCreatedAt ? row.createdAt : undefined,
2251
+ decimals: 6,
2252
+ holderCount: holderCounts?.[row.token],
2253
+ id: row.token,
2254
+ logoUri: logoByAddress[row.token],
2255
+ name: row.name,
2256
+ quoteToken: wantQuoteToken ? created?.quoteToken : undefined,
2257
+ symbol: row.symbol,
2258
+ transferStats: statsByAddress?.[row.token],
2259
+ verified: snapshot.byAddress.has(row.token),
2260
+ }
2261
+ }),
2262
+ nextCursor: chunk.nextCursor,
2263
+ }
2264
+ }
2265
+
2266
+ declare namespace getTokens {
2267
+ type Options = {
2268
+ /**
2269
+ * Exact token addresses to fetch as a single page in input order;
2270
+ * takes precedence over the verified and indexed listing modes.
2271
+ */
2272
+ addresses?: readonly z.output<typeof Schema.Address>[] | undefined
2273
+ chainId: z.output<typeof Schema.ChainId>
2274
+ /** Only include tokens denominated in this currency (e.g. `USD`). */
2275
+ currency?: string | undefined
2276
+ /** Opaque keyset cursor anchoring the page; omit for the head page. */
2277
+ cursor?: string | undefined
2278
+ include?: readonly z.output<typeof schema.getToken.Include>[] | undefined
2279
+ limit: number
2280
+ /** 1-indexed page number (positional pagination; exclusive with `cursor`). */
2281
+ page?: number | undefined
2282
+ order: 'asc' | 'desc'
2283
+ /** When true, restrict the page to the curated verified token list. */
2284
+ verified?: boolean | undefined
2285
+ }
2286
+ }
2287
+
2288
+ function isTokenNotFound(cause: unknown) {
2289
+ if (cause instanceof ContractFunctionExecutionError) return true
2290
+ if (cause instanceof Error && cause.message.includes('invalid tip20 address')) return true
2291
+ return false
2292
+ }