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