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.
- package/LICENSE +21 -0
- package/README.md +119 -0
- package/dist/ApiKey.d.ts +65 -0
- package/dist/ApiKey.d.ts.map +1 -0
- package/dist/ApiKey.js +105 -0
- package/dist/ApiKey.js.map +1 -0
- package/dist/ApiKeySource.d.ts +166 -0
- package/dist/ApiKeySource.d.ts.map +1 -0
- package/dist/ApiKeySource.js +234 -0
- package/dist/ApiKeySource.js.map +1 -0
- package/dist/App.d.ts +19317 -0
- package/dist/App.d.ts.map +1 -0
- package/dist/App.js +612 -0
- package/dist/App.js.map +1 -0
- package/dist/Assets.d.ts +44 -0
- package/dist/Assets.d.ts.map +1 -0
- package/dist/Assets.js +23 -0
- package/dist/Assets.js.map +1 -0
- package/dist/Auth.d.ts +2 -0
- package/dist/Auth.d.ts.map +1 -0
- package/dist/Auth.js +2 -0
- package/dist/Auth.js.map +1 -0
- package/dist/Cli.d.ts +21 -0
- package/dist/Cli.d.ts.map +1 -0
- package/dist/Cli.js +13 -0
- package/dist/Cli.js.map +1 -0
- package/dist/Client.d.ts +20 -0
- package/dist/Client.d.ts.map +1 -0
- package/dist/Client.js +24 -0
- package/dist/Client.js.map +1 -0
- package/dist/Metrics.d.ts +70 -0
- package/dist/Metrics.d.ts.map +1 -0
- package/dist/Metrics.js +91 -0
- package/dist/Metrics.js.map +1 -0
- package/dist/Scope.d.ts +35 -0
- package/dist/Scope.d.ts.map +1 -0
- package/dist/Scope.js +38 -0
- package/dist/Scope.js.map +1 -0
- package/dist/Webhooks.d.ts +79 -0
- package/dist/Webhooks.d.ts.map +1 -0
- package/dist/Webhooks.js +107 -0
- package/dist/Webhooks.js.map +1 -0
- package/dist/admin/Access.d.ts +41 -0
- package/dist/admin/Access.d.ts.map +1 -0
- package/dist/admin/Access.js +75 -0
- package/dist/admin/Access.js.map +1 -0
- package/dist/admin/App.d.ts +1221 -0
- package/dist/admin/App.d.ts.map +1 -0
- package/dist/admin/App.js +113 -0
- package/dist/admin/App.js.map +1 -0
- package/dist/admin/apps/api-keys.d.ts +209 -0
- package/dist/admin/apps/api-keys.d.ts.map +1 -0
- package/dist/admin/apps/api-keys.js +129 -0
- package/dist/admin/apps/api-keys.js.map +1 -0
- package/dist/admin/apps/scopes.d.ts +46 -0
- package/dist/admin/apps/scopes.d.ts.map +1 -0
- package/dist/admin/apps/scopes.js +41 -0
- package/dist/admin/apps/scopes.js.map +1 -0
- package/dist/admin/apps/verified-tokens.d.ts +1206 -0
- package/dist/admin/apps/verified-tokens.d.ts.map +1 -0
- package/dist/admin/apps/verified-tokens.js +485 -0
- package/dist/admin/apps/verified-tokens.js.map +1 -0
- package/dist/admin/index.d.ts +3 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +3 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/ui.generated.d.ts +11 -0
- package/dist/admin/ui.generated.d.ts.map +1 -0
- package/dist/admin/ui.generated.js +43 -0
- package/dist/admin/ui.generated.js.map +1 -0
- package/dist/apps/activities.d.ts +18725 -0
- package/dist/apps/activities.d.ts.map +1 -0
- package/dist/apps/activities.js +3138 -0
- package/dist/apps/activities.js.map +1 -0
- package/dist/apps/addresses.d.ts +3662 -0
- package/dist/apps/addresses.d.ts.map +1 -0
- package/dist/apps/addresses.js +22 -0
- package/dist/apps/addresses.js.map +1 -0
- package/dist/apps/balances.d.ts +342 -0
- package/dist/apps/balances.d.ts.map +1 -0
- package/dist/apps/balances.js +393 -0
- package/dist/apps/balances.js.map +1 -0
- package/dist/apps/blocks.d.ts +931 -0
- package/dist/apps/blocks.d.ts.map +1 -0
- package/dist/apps/blocks.js +689 -0
- package/dist/apps/blocks.js.map +1 -0
- package/dist/apps/coingecko.d.ts +1053 -0
- package/dist/apps/coingecko.d.ts.map +1 -0
- package/dist/apps/coingecko.js +1015 -0
- package/dist/apps/coingecko.js.map +1 -0
- package/dist/apps/exchanges.d.ts +3000 -0
- package/dist/apps/exchanges.d.ts.map +1 -0
- package/dist/apps/exchanges.js +3570 -0
- package/dist/apps/exchanges.js.map +1 -0
- package/dist/apps/fee-amm.d.ts +640 -0
- package/dist/apps/fee-amm.d.ts.map +1 -0
- package/dist/apps/fee-amm.js +815 -0
- package/dist/apps/fee-amm.js.map +1 -0
- package/dist/apps/indexer.d.ts +116 -0
- package/dist/apps/indexer.d.ts.map +1 -0
- package/dist/apps/indexer.js +270 -0
- package/dist/apps/indexer.js.map +1 -0
- package/dist/apps/receipts.d.ts +1438 -0
- package/dist/apps/receipts.d.ts.map +1 -0
- package/dist/apps/receipts.js +874 -0
- package/dist/apps/receipts.js.map +1 -0
- package/dist/apps/rpc.d.ts +173 -0
- package/dist/apps/rpc.d.ts.map +1 -0
- package/dist/apps/rpc.js +191 -0
- package/dist/apps/rpc.js.map +1 -0
- package/dist/apps/tokenlist.d.ts +192 -0
- package/dist/apps/tokenlist.d.ts.map +1 -0
- package/dist/apps/tokenlist.js +153 -0
- package/dist/apps/tokenlist.js.map +1 -0
- package/dist/apps/tokens.d.ts +2246 -0
- package/dist/apps/tokens.d.ts.map +1 -0
- package/dist/apps/tokens.js +1789 -0
- package/dist/apps/tokens.js.map +1 -0
- package/dist/apps/transactions.d.ts +2327 -0
- package/dist/apps/transactions.d.ts.map +1 -0
- package/dist/apps/transactions.js +975 -0
- package/dist/apps/transactions.js.map +1 -0
- package/dist/apps/transfers.d.ts +589 -0
- package/dist/apps/transfers.d.ts.map +1 -0
- package/dist/apps/transfers.js +769 -0
- package/dist/apps/transfers.js.map +1 -0
- package/dist/apps/verified-tokens.d.ts +544 -0
- package/dist/apps/verified-tokens.d.ts.map +1 -0
- package/dist/apps/verified-tokens.js +234 -0
- package/dist/apps/verified-tokens.js.map +1 -0
- package/dist/apps/webhooks.d.ts +7684 -0
- package/dist/apps/webhooks.d.ts.map +1 -0
- package/dist/apps/webhooks.js +2112 -0
- package/dist/apps/webhooks.js.map +1 -0
- package/dist/cloudflare.d.ts +32 -0
- package/dist/cloudflare.d.ts.map +1 -0
- package/dist/cloudflare.js +45 -0
- package/dist/cloudflare.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/Auth.d.ts +298 -0
- package/dist/internal/Auth.d.ts.map +1 -0
- package/dist/internal/Auth.js +618 -0
- package/dist/internal/Auth.js.map +1 -0
- package/dist/internal/Cache.d.ts +74 -0
- package/dist/internal/Cache.d.ts.map +1 -0
- package/dist/internal/Cache.js +215 -0
- package/dist/internal/Cache.js.map +1 -0
- package/dist/internal/Cursor.d.ts +79 -0
- package/dist/internal/Cursor.d.ts.map +1 -0
- package/dist/internal/Cursor.js +116 -0
- package/dist/internal/Cursor.js.map +1 -0
- package/dist/internal/EdgeCache.d.ts +59 -0
- package/dist/internal/EdgeCache.d.ts.map +1 -0
- package/dist/internal/EdgeCache.js +174 -0
- package/dist/internal/EdgeCache.js.map +1 -0
- package/dist/internal/Log.d.ts +104 -0
- package/dist/internal/Log.d.ts.map +1 -0
- package/dist/internal/Log.js +118 -0
- package/dist/internal/Log.js.map +1 -0
- package/dist/internal/MetricSink.d.ts +65 -0
- package/dist/internal/MetricSink.d.ts.map +1 -0
- package/dist/internal/MetricSink.js +146 -0
- package/dist/internal/MetricSink.js.map +1 -0
- package/dist/internal/Mpp.d.ts +71 -0
- package/dist/internal/Mpp.d.ts.map +1 -0
- package/dist/internal/Mpp.js +105 -0
- package/dist/internal/Mpp.js.map +1 -0
- package/dist/internal/OpenApi.d.ts +313 -0
- package/dist/internal/OpenApi.d.ts.map +1 -0
- package/dist/internal/OpenApi.fonts.d.ts +2 -0
- package/dist/internal/OpenApi.fonts.d.ts.map +1 -0
- package/dist/internal/OpenApi.fonts.js +39 -0
- package/dist/internal/OpenApi.fonts.js.map +1 -0
- package/dist/internal/OpenApi.js +335 -0
- package/dist/internal/OpenApi.js.map +1 -0
- package/dist/internal/OpenApi.logo.d.ts +6 -0
- package/dist/internal/OpenApi.logo.d.ts.map +1 -0
- package/dist/internal/OpenApi.logo.js +19 -0
- package/dist/internal/OpenApi.logo.js.map +1 -0
- package/dist/internal/Path.d.ts +5 -0
- package/dist/internal/Path.d.ts.map +1 -0
- package/dist/internal/Path.js +16 -0
- package/dist/internal/Path.js.map +1 -0
- package/dist/internal/RateLimit.d.ts +60 -0
- package/dist/internal/RateLimit.d.ts.map +1 -0
- package/dist/internal/RateLimit.js +48 -0
- package/dist/internal/RateLimit.js.map +1 -0
- package/dist/internal/Response.d.ts +132 -0
- package/dist/internal/Response.d.ts.map +1 -0
- package/dist/internal/Response.js +72 -0
- package/dist/internal/Response.js.map +1 -0
- package/dist/internal/Schema.d.ts +257 -0
- package/dist/internal/Schema.d.ts.map +1 -0
- package/dist/internal/Schema.js +360 -0
- package/dist/internal/Schema.js.map +1 -0
- package/dist/internal/Store.d.ts +204 -0
- package/dist/internal/Store.d.ts.map +1 -0
- package/dist/internal/Store.js +291 -0
- package/dist/internal/Store.js.map +1 -0
- package/dist/internal/Tidx.d.ts +120 -0
- package/dist/internal/Tidx.d.ts.map +1 -0
- package/dist/internal/Tidx.js +156 -0
- package/dist/internal/Tidx.js.map +1 -0
- package/dist/internal/Timing.d.ts +22 -0
- package/dist/internal/Timing.d.ts.map +1 -0
- package/dist/internal/Timing.js +32 -0
- package/dist/internal/Timing.js.map +1 -0
- package/dist/internal/Ttl.d.ts +25 -0
- package/dist/internal/Ttl.d.ts.map +1 -0
- package/dist/internal/Ttl.js +49 -0
- package/dist/internal/Ttl.js.map +1 -0
- package/dist/internal/Value.d.ts +47 -0
- package/dist/internal/Value.d.ts.map +1 -0
- package/dist/internal/Value.js +122 -0
- package/dist/internal/Value.js.map +1 -0
- package/dist/internal/VerifiedTokens.d.ts +228 -0
- package/dist/internal/VerifiedTokens.d.ts.map +1 -0
- package/dist/internal/VerifiedTokens.js +450 -0
- package/dist/internal/VerifiedTokens.js.map +1 -0
- package/dist/internal/Viem.d.ts +2082 -0
- package/dist/internal/Viem.d.ts.map +1 -0
- package/dist/internal/Viem.js +63 -0
- package/dist/internal/Viem.js.map +1 -0
- package/dist/internal/WebhookDestination.d.ts +198 -0
- package/dist/internal/WebhookDestination.d.ts.map +1 -0
- package/dist/internal/WebhookDestination.js +170 -0
- package/dist/internal/WebhookDestination.js.map +1 -0
- package/dist/internal/Webhooks.d.ts +433 -0
- package/dist/internal/Webhooks.d.ts.map +1 -0
- package/dist/internal/Webhooks.js +489 -0
- package/dist/internal/Webhooks.js.map +1 -0
- package/dist/internal/webhookDestinations/betterStack.d.ts +26 -0
- package/dist/internal/webhookDestinations/betterStack.d.ts.map +1 -0
- package/dist/internal/webhookDestinations/betterStack.js +76 -0
- package/dist/internal/webhookDestinations/betterStack.js.map +1 -0
- package/dist/internal/webhookDestinations/slack.d.ts +59 -0
- package/dist/internal/webhookDestinations/slack.d.ts.map +1 -0
- package/dist/internal/webhookDestinations/slack.js +422 -0
- package/dist/internal/webhookDestinations/slack.js.map +1 -0
- package/dist/internal/webhookDestinations/url.d.ts +17 -0
- package/dist/internal/webhookDestinations/url.d.ts.map +1 -0
- package/dist/internal/webhookDestinations/url.js +130 -0
- package/dist/internal/webhookDestinations/url.js.map +1 -0
- package/dist/openrpc.json +5302 -0
- package/package.json +46 -4
- package/seeds/31318/icons/0x20c0000000000000000000000000000000000000.svg +1 -0
- package/seeds/31318/icons/0x20c0000000000000000000000000000000000001.svg +1 -0
- package/seeds/31318/icons/0x20c0000000000000000000000000000000000002.svg +1 -0
- package/seeds/31318/icons/0x20c0000000000000000000000000000000000003.svg +1 -0
- package/seeds/31318/verified-tokens.json +30 -0
- package/seeds/4217/icons/0x20c0000000000000000000000000000000000000.svg +25 -0
- package/seeds/4217/icons/0x20c000000000000000000000048c8f36df1c9a4a.svg +1 -0
- package/seeds/4217/icons/0x20c0000000000000000000000520792dcccccccc.svg +1 -0
- package/seeds/4217/icons/0x20c0000000000000000000000a6da882d075a4c3.svg +4 -0
- package/seeds/4217/icons/0x20c000000000000000000000111111111e910f0f.svg +30 -0
- package/seeds/4217/icons/0x20c00000000000000000000014f22ca97301eb73.svg +1 -0
- package/seeds/4217/icons/0x20c0000000000000000000001621e21f71cf12fb.svg +1 -0
- package/seeds/4217/icons/0x20c0000000000000000000002f52d5cc21a3207b.svg +1 -0
- package/seeds/4217/icons/0x20c0000000000000000000003158081efd85bfc2.svg +11 -0
- package/seeds/4217/icons/0x20c0000000000000000000003554d28269e0f3c2.svg +1 -0
- package/seeds/4217/icons/0x20c000000000000000000000383a23bacb546ab9.svg +1 -0
- package/seeds/4217/icons/0x20c00000000000000000000042109aef2f8b28e1.png +0 -0
- package/seeds/4217/icons/0x20c0000000000000000000005c0bac7cef389a11.svg +1 -0
- package/seeds/4217/icons/0x20c0000000000000000000006fd9a167923ba194.png +0 -0
- package/seeds/4217/icons/0x20c0000000000000000000007f7ba549dd0251b9.svg +1 -0
- package/seeds/4217/icons/0x20c0000000000000000000008191667423f70e67.svg +5 -0
- package/seeds/4217/icons/0x20c0000000000000000000008ee4fcff88888888.svg +1 -0
- package/seeds/4217/icons/0x20c0000000000000000000009a4a4b17e0dc6651.svg +1 -0
- package/seeds/4217/icons/0x20c000000000000000000000ab02d39df30bd17e.svg +1 -0
- package/seeds/4217/icons/0x20c000000000000000000000ae247a1130450f09.svg +1 -0
- package/seeds/4217/icons/0x20c000000000000000000000aeed2ec36a54d0e5.svg +1 -0
- package/seeds/4217/icons/0x20c000000000000000000000b9537d11c60e8b50.svg +1 -0
- package/seeds/4217/icons/0x20c000000000000000000000bd95bfb69fbe6ce3.svg +1 -0
- package/seeds/4217/icons/0x20c000000000000000000000c412ec89d0c08be5.svg +1 -0
- package/seeds/4217/icons/0x20c000000000000000000000f047dd7018e50367.png +0 -0
- package/seeds/4217/verified-tokens.json +177 -0
- package/seeds/42431/icons/0x20c0000000000000000000000000000000000000.svg +25 -0
- package/seeds/42431/icons/0x20c0000000000000000000000000000000000001.svg +1 -0
- package/seeds/42431/icons/0x20c0000000000000000000000000000000000002.svg +1 -0
- package/seeds/42431/icons/0x20c0000000000000000000000000000000000003.svg +1 -0
- package/seeds/42431/icons/0x20c0000000000000000000009e8d7eb59b783726.svg +1 -0
- package/seeds/42431/icons/0x20c000000000000000000000d72572838bbee59c.svg +1 -0
- package/seeds/42431/verified-tokens.json +44 -0
- package/seeds/openrpc/README.md +27 -0
- package/seeds/openrpc/execution-apis.json +8989 -0
- package/src/ApiKey.test.ts +91 -0
- package/src/ApiKey.ts +122 -0
- package/src/ApiKeySource.test-d.ts +11 -0
- package/src/ApiKeySource.test.ts +295 -0
- package/src/ApiKeySource.ts +303 -0
- package/src/App.test-d.ts +38 -0
- package/src/App.test.ts +65927 -0
- package/src/App.ts +855 -0
- package/src/Assets.ts +58 -0
- package/src/Auth.ts +1 -0
- package/src/Cli.test.ts +81 -0
- package/src/Cli.ts +38 -0
- package/src/Client.test-d.ts +1438 -0
- package/src/Client.test.ts +134 -0
- package/src/Client.ts +45 -0
- package/src/Metrics.ts +154 -0
- package/src/Scope.ts +50 -0
- package/src/Webhooks.test.ts +49 -0
- package/src/Webhooks.ts +172 -0
- package/src/admin/Access.test.ts +130 -0
- package/src/admin/Access.ts +87 -0
- package/src/admin/App.test.ts +225 -0
- package/src/admin/App.ts +216 -0
- package/src/admin/apps/api-keys.test.ts +156 -0
- package/src/admin/apps/api-keys.ts +157 -0
- package/src/admin/apps/scopes.test.ts +52 -0
- package/src/admin/apps/scopes.ts +47 -0
- package/src/admin/apps/verified-tokens.test.ts +415 -0
- package/src/admin/apps/verified-tokens.ts +566 -0
- package/src/admin/index.ts +2 -0
- package/src/admin/ui/plugin.ts +135 -0
- package/src/admin/ui/src/components/Checkbox.tsx +22 -0
- package/src/admin/ui/src/components/Dialog.tsx +32 -0
- package/src/admin/ui/src/components/MobileProfile.tsx +31 -0
- package/src/admin/ui/src/components/Page.tsx +53 -0
- package/src/admin/ui/src/components/TempoLogo.tsx +37 -0
- package/src/admin/ui/src/fonts/Pilat-Demi.woff2 +0 -0
- package/src/admin/ui/src/fonts/Pilat-Regular.woff2 +0 -0
- package/src/admin/ui/src/lib/api-keys.ts +63 -0
- package/src/admin/ui/src/lib/client.ts +15 -0
- package/src/admin/ui/src/lib/config.ts +57 -0
- package/src/admin/ui/src/lib/query-client.ts +26 -0
- package/src/admin/ui/src/lib/scopes.ts +25 -0
- package/src/admin/ui/src/lib/verified-tokens.ts +89 -0
- package/src/admin/ui/src/lib/wagmi.ts +18 -0
- package/src/admin/ui/src/routeTree.gen.ts +104 -0
- package/src/admin/ui/src/router.tsx +25 -0
- package/src/admin/ui/src/routes/__root.tsx +219 -0
- package/src/admin/ui/src/routes/api-keys.tsx +329 -0
- package/src/admin/ui/src/routes/index.tsx +7 -0
- package/src/admin/ui/src/routes/verified-tokens.tsx +331 -0
- package/src/admin/ui/src/styles.css +20 -0
- package/src/admin/ui/tsconfig.json +16 -0
- package/src/admin/ui/vite.config.ts +29 -0
- package/src/admin/ui.generated.ts +60 -0
- package/src/apps/activities.test.ts +1020 -0
- package/src/apps/activities.ts +3934 -0
- package/src/apps/addresses.ts +24 -0
- package/src/apps/balances.test.ts +274 -0
- package/src/apps/balances.ts +533 -0
- package/src/apps/blocks.test.ts +385 -0
- package/src/apps/blocks.ts +896 -0
- package/src/apps/coingecko.test.ts +218 -0
- package/src/apps/coingecko.ts +1383 -0
- package/src/apps/exchanges.test.ts +958 -0
- package/src/apps/exchanges.ts +4773 -0
- package/src/apps/fee-amm.test.ts +321 -0
- package/src/apps/fee-amm.ts +1121 -0
- package/src/apps/indexer.test.ts +153 -0
- package/src/apps/indexer.ts +330 -0
- package/src/apps/receipts.test.ts +285 -0
- package/src/apps/receipts.ts +1193 -0
- package/src/apps/rpc.test.ts +302 -0
- package/src/apps/rpc.ts +222 -0
- package/src/apps/tokenlist.test.ts +80 -0
- package/src/apps/tokenlist.ts +207 -0
- package/src/apps/tokens.test.ts +1062 -0
- package/src/apps/tokens.ts +2292 -0
- package/src/apps/transactions.test.ts +408 -0
- package/src/apps/transactions.ts +1363 -0
- package/src/apps/transfers.test.ts +371 -0
- package/src/apps/transfers.ts +1116 -0
- package/src/apps/verified-tokens.test.ts +114 -0
- package/src/apps/verified-tokens.ts +295 -0
- package/src/apps/webhooks.test.ts +1937 -0
- package/src/apps/webhooks.ts +2926 -0
- package/src/cloudflare.ts +55 -0
- package/src/index.ts +15 -0
- package/src/internal/Auth.test.ts +980 -0
- package/src/internal/Auth.ts +1012 -0
- package/src/internal/Cache.test.ts +240 -0
- package/src/internal/Cache.ts +262 -0
- package/src/internal/Cursor.test.ts +100 -0
- package/src/internal/Cursor.ts +149 -0
- package/src/internal/EdgeCache.test.ts +234 -0
- package/src/internal/EdgeCache.ts +224 -0
- package/src/internal/Errors.ts +14 -0
- package/src/internal/Log.test.ts +244 -0
- package/src/internal/Log.ts +216 -0
- package/src/internal/MetricSink.ts +220 -0
- package/src/internal/Mpp.test.ts +64 -0
- package/src/internal/Mpp.ts +141 -0
- package/src/internal/OpenApi.fonts.ts +38 -0
- package/src/internal/OpenApi.logo.ts +18 -0
- package/src/internal/OpenApi.ts +488 -0
- package/src/internal/Path.test.ts +14 -0
- package/src/internal/Path.ts +15 -0
- package/src/internal/RateLimit.test.ts +93 -0
- package/src/internal/RateLimit.ts +93 -0
- package/src/internal/Response.ts +131 -0
- package/src/internal/Schema.test.ts +34 -0
- package/src/internal/Schema.ts +523 -0
- package/src/internal/Store.test.ts +545 -0
- package/src/internal/Store.ts +526 -0
- package/src/internal/Tidx.local.test.ts +33 -0
- package/src/internal/Tidx.test.ts +294 -0
- package/src/internal/Tidx.ts +238 -0
- package/src/internal/Timing.test.ts +22 -0
- package/src/internal/Timing.ts +62 -0
- package/src/internal/Ttl.test.ts +18 -0
- package/src/internal/Ttl.ts +59 -0
- package/src/internal/Value.ts +114 -0
- package/src/internal/VerifiedTokens.test.ts +333 -0
- package/src/internal/VerifiedTokens.ts +645 -0
- package/src/internal/Viem.test.ts +115 -0
- package/src/internal/Viem.ts +165 -0
- package/src/internal/WebhookDestination.test.ts +648 -0
- package/src/internal/WebhookDestination.ts +331 -0
- package/src/internal/Webhooks.test.ts +668 -0
- package/src/internal/Webhooks.ts +940 -0
- package/src/internal/webhookDestinations/betterStack.ts +91 -0
- package/src/internal/webhookDestinations/slack.ts +496 -0
- package/src/internal/webhookDestinations/url.ts +128 -0
- 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
|