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,1116 @@
|
|
|
1
|
+
import { Hono, type Context } from 'hono'
|
|
2
|
+
import { Address, Hex } from 'ox'
|
|
3
|
+
import { Addresses } from 'viem/tempo'
|
|
4
|
+
import * as z from 'zod/mini'
|
|
5
|
+
|
|
6
|
+
import type * as App from '../App.js'
|
|
7
|
+
import * as Auth from '../internal/Auth.js'
|
|
8
|
+
import * as Cache from '../internal/Cache.js'
|
|
9
|
+
import * as Cursor from '../internal/Cursor.js'
|
|
10
|
+
import * as Mpp from '../internal/Mpp.js'
|
|
11
|
+
import * as OpenApi from '../internal/OpenApi.js'
|
|
12
|
+
import * as Response from '../internal/Response.js'
|
|
13
|
+
import * as Schema from '../internal/Schema.js'
|
|
14
|
+
import * as Store from '../internal/Store.js'
|
|
15
|
+
import type * as Tidx from '../internal/Tidx.js'
|
|
16
|
+
import * as Timing from '../internal/Timing.js'
|
|
17
|
+
import * as Ttl from '../internal/Ttl.js'
|
|
18
|
+
import * as Value from '../internal/Value.js'
|
|
19
|
+
import * as VerifiedTokens from '../internal/VerifiedTokens.js'
|
|
20
|
+
import * as Tokens from './tokens.js'
|
|
21
|
+
|
|
22
|
+
// TIP-20 tokens emit the standard ERC-20 Transfer event. Supplying the
|
|
23
|
+
// signature exposes a decoded `Transfer` CTE (with `from`/`to`/`value`) to
|
|
24
|
+
// the indexer SQL.
|
|
25
|
+
export const signature = 'event Transfer(address indexed from, address indexed to, uint256 value)'
|
|
26
|
+
|
|
27
|
+
// A memo'd TIP-20 transfer emits `TransferWithMemo` alongside the plain
|
|
28
|
+
// `Transfer`; the two rows match on `(tx_hash, from, to, amount, token)`. A
|
|
29
|
+
// text memo is decoded into `memo`; an MPP attribution fingerprint memo is
|
|
30
|
+
// resolved into `attribution` instead.
|
|
31
|
+
const memoSignature =
|
|
32
|
+
'event TransferWithMemo(address indexed from, address indexed to, uint256 amount, bytes32 indexed memo)'
|
|
33
|
+
|
|
34
|
+
/** Zod schemas owned by the transfer handlers. */
|
|
35
|
+
export namespace schema {
|
|
36
|
+
/**
|
|
37
|
+
* The token moved by a transfer: its contract address and the transferred
|
|
38
|
+
* amount, plus the trimmed token reference spread in when requested via
|
|
39
|
+
* `include=token`. Reference fields are absent on the base (no-`include`)
|
|
40
|
+
* response and best-effort when requested (a row whose metadata is
|
|
41
|
+
* unavailable keeps just `address`/`amount`). Shared by every per-row
|
|
42
|
+
* transfer response across the API.
|
|
43
|
+
*
|
|
44
|
+
* The optional metadata fields are inlined here (rather than spread from
|
|
45
|
+
* `Tokens.schema.Token` via `z.partial`) so this module avoids referencing
|
|
46
|
+
* the `tokens` schema namespace at module-init time — `transfers` and
|
|
47
|
+
* `tokens` import each other for runtime helpers (`Transfers.query`,
|
|
48
|
+
* `Tokens.resolveToken`), and the schema-evaluation back-edge would
|
|
49
|
+
* otherwise deadlock the namespace.
|
|
50
|
+
*/
|
|
51
|
+
export const SourceToken = z
|
|
52
|
+
.object({
|
|
53
|
+
address: Schema.TokenAddress.check(z.describe('The TIP-20 token contract address on Tempo.')),
|
|
54
|
+
amount: z
|
|
55
|
+
.string()
|
|
56
|
+
.check(
|
|
57
|
+
z.regex(/^\d+$/),
|
|
58
|
+
z.describe(
|
|
59
|
+
'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.',
|
|
60
|
+
),
|
|
61
|
+
z.meta({ examples: ['1000000'] }),
|
|
62
|
+
),
|
|
63
|
+
currency: z
|
|
64
|
+
.optional(z.string())
|
|
65
|
+
.check(
|
|
66
|
+
z.describe(
|
|
67
|
+
'The currency label for this token, such as `USD` for USD-denominated stablecoins.',
|
|
68
|
+
),
|
|
69
|
+
z.meta({ examples: ['USD'] }),
|
|
70
|
+
),
|
|
71
|
+
decimals: z
|
|
72
|
+
.optional(z.number().check(z.int(), z.nonnegative()))
|
|
73
|
+
.check(
|
|
74
|
+
z.describe(
|
|
75
|
+
'The number of decimal places the token uses; Tempo stablecoins typically use 6.',
|
|
76
|
+
),
|
|
77
|
+
z.meta({ examples: [6] }),
|
|
78
|
+
),
|
|
79
|
+
logoUri: z.optional(z.string()).check(
|
|
80
|
+
z.describe('A URL for the token’s logo image, when one is available.'),
|
|
81
|
+
z.meta({
|
|
82
|
+
examples: [
|
|
83
|
+
'https://api.tempo.xyz/data/4217/icons/0x20c0000000000000000000008f5425160ebe5525.svg',
|
|
84
|
+
],
|
|
85
|
+
}),
|
|
86
|
+
),
|
|
87
|
+
name: z
|
|
88
|
+
.optional(z.string())
|
|
89
|
+
.check(z.describe('The token’s human-readable name.'), z.meta({ examples: ['USD Coin'] })),
|
|
90
|
+
symbol: z
|
|
91
|
+
.optional(z.string())
|
|
92
|
+
.check(
|
|
93
|
+
z.describe('The short ticker symbol wallets and apps show for this token.'),
|
|
94
|
+
z.meta({ examples: ['USDC'] }),
|
|
95
|
+
),
|
|
96
|
+
verified: z
|
|
97
|
+
.optional(z.boolean())
|
|
98
|
+
.check(
|
|
99
|
+
z.describe('Whether this token is in Tempo’s curated verified token list.'),
|
|
100
|
+
z.meta({ examples: [true] }),
|
|
101
|
+
),
|
|
102
|
+
})
|
|
103
|
+
.check(
|
|
104
|
+
z.describe(
|
|
105
|
+
'The token and amount that moved in this transfer, with token metadata added when you request `include=token`.',
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Filter predicates shared verbatim by the `GET /transfers` list query
|
|
111
|
+
* ({@link getTransfers.Query}, which spreads `Predicates.shape`) and `transfer`
|
|
112
|
+
* webhook subscriptions (`Webhooks.schema.TransferFilters`), so the two never
|
|
113
|
+
* drift. Read-only concerns (pagination, windowing, ordering, `include`,
|
|
114
|
+
* `chainId`) are *not* here — they live only on the list query and have no
|
|
115
|
+
* meaning for an open-ended subscription. Consumers that need different
|
|
116
|
+
* per-field docs (e.g. the list query's mutually-exclusive `address`) override
|
|
117
|
+
* that one field.
|
|
118
|
+
*/
|
|
119
|
+
export const Predicates = z.object({
|
|
120
|
+
address: z
|
|
121
|
+
.optional(Schema.Address)
|
|
122
|
+
.check(
|
|
123
|
+
z.describe('Match transfers where this account is either the sender or the recipient.'),
|
|
124
|
+
),
|
|
125
|
+
recipient: z
|
|
126
|
+
.optional(Schema.Address)
|
|
127
|
+
.check(z.describe('Match transfers sent to this recipient address (`to`).')),
|
|
128
|
+
sender: z
|
|
129
|
+
.optional(Schema.Address)
|
|
130
|
+
.check(z.describe('Match transfers sent from this sender address (`from`).')),
|
|
131
|
+
token: z
|
|
132
|
+
.optional(Schema.TokenAddress)
|
|
133
|
+
.check(z.describe('Match transfers for this TIP-20 token contract address.')),
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
/** Schemas for the getTransfers (list) operation. */
|
|
137
|
+
export namespace getTransfers {
|
|
138
|
+
/** Optional related resources that callers opt into via `include`. */
|
|
139
|
+
export const Include = z
|
|
140
|
+
.enum(['crossToken', 'memo', 'token', 'totalCount'])
|
|
141
|
+
.check(z.describe('Related resources to include only when you request them with `include`.'))
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Parses a comma-separated `include` query value into a list of optional
|
|
145
|
+
* resources to embed. Extra lookups (token metadata, the `TransferWithMemo`
|
|
146
|
+
* memo/attribution decode, the capped total count) each run only when their
|
|
147
|
+
* resource is requested, keeping the base transfer page fast.
|
|
148
|
+
*/
|
|
149
|
+
export const includeQuery = Schema.includeQuery(
|
|
150
|
+
Include,
|
|
151
|
+
'Comma-separated related resources to include, such as `token,memo,crossToken,totalCount`.',
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Query parameters for top-level transfer list requests. `address` is a
|
|
156
|
+
* convenience for "either side" — passing it alongside an explicit
|
|
157
|
+
* `sender` or `recipient` is rejected as ambiguous (the helper would have
|
|
158
|
+
* to AND/OR them in a non-obvious way).
|
|
159
|
+
*/
|
|
160
|
+
export const Query = z
|
|
161
|
+
.strictObject({
|
|
162
|
+
// Predicate fields (`address`, `recipient`, `sender`, `token`) are shared
|
|
163
|
+
// verbatim with `transfer` webhook filters via `schema.Predicates`;
|
|
164
|
+
// `address` is overridden below for its list-only mutual-exclusion note.
|
|
165
|
+
...schema.Predicates.shape,
|
|
166
|
+
address: z
|
|
167
|
+
.optional(Schema.Address)
|
|
168
|
+
.check(
|
|
169
|
+
z.describe(
|
|
170
|
+
'Only include transfers where this account is the sender or recipient. Use this shortcut by itself, not with `sender` or `recipient`.',
|
|
171
|
+
),
|
|
172
|
+
),
|
|
173
|
+
'blockNumber.from': Schema.blockNumberBound('transfers', 'from'),
|
|
174
|
+
'blockNumber.to': Schema.blockNumberBound('transfers', 'to'),
|
|
175
|
+
chainId: Schema.ChainIdQuery,
|
|
176
|
+
cursor: Schema.Cursor,
|
|
177
|
+
include: includeQuery,
|
|
178
|
+
limit: Schema.Limit,
|
|
179
|
+
order: Schema.Order,
|
|
180
|
+
page: Schema.Page,
|
|
181
|
+
'timestamp.from': Schema.timestampBound('transfers', 'from'),
|
|
182
|
+
'timestamp.to': Schema.timestampBound('transfers', 'to'),
|
|
183
|
+
})
|
|
184
|
+
.check(
|
|
185
|
+
z.refine((query) => !(query.address && (query.sender || query.recipient)), {
|
|
186
|
+
error:
|
|
187
|
+
'`address` is a shortcut for `sender` or `recipient`; pass either `address` or one explicit side, not both.',
|
|
188
|
+
path: ['address'],
|
|
189
|
+
}),
|
|
190
|
+
...Schema.pageChecks(),
|
|
191
|
+
)
|
|
192
|
+
.check(z.describe('Query parameters for listing token transfer events.'))
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* A single transfer row. The token contract is carried per-row via
|
|
196
|
+
* `sourceToken.address` because `/transfers` is unscoped and rows can span
|
|
197
|
+
* multiple contracts.
|
|
198
|
+
*/
|
|
199
|
+
export const Transfer = z
|
|
200
|
+
.object({
|
|
201
|
+
attribution: z
|
|
202
|
+
.optional(z.string())
|
|
203
|
+
.check(
|
|
204
|
+
z.describe(
|
|
205
|
+
'The resolved MPP service name, when the transfer memo matches a known service fingerprint. Returned when you request `include=memo`.',
|
|
206
|
+
),
|
|
207
|
+
z.meta({ examples: ['example-service'] }),
|
|
208
|
+
),
|
|
209
|
+
blockNumber: z
|
|
210
|
+
.number()
|
|
211
|
+
.check(
|
|
212
|
+
z.int(),
|
|
213
|
+
z.nonnegative(),
|
|
214
|
+
z.describe('The block number where this transfer was recorded.'),
|
|
215
|
+
z.meta({ examples: [23456789] }),
|
|
216
|
+
),
|
|
217
|
+
destinationToken: z
|
|
218
|
+
.optional(schema.SourceToken)
|
|
219
|
+
.check(
|
|
220
|
+
z.describe(
|
|
221
|
+
'The token and amount delivered to the recipient when it differs from `sourceToken` — a cross-token transfer routed through the DEX, where `sourceToken` is the token swapped in. Omitted for same-token transfers, and only returned when you request `include=crossToken`.',
|
|
222
|
+
),
|
|
223
|
+
),
|
|
224
|
+
id: z.string().check(
|
|
225
|
+
z.describe(
|
|
226
|
+
'A stable resource ID for this transfer, built from `${transactionHash}-${logIndex}`.',
|
|
227
|
+
),
|
|
228
|
+
z.meta({
|
|
229
|
+
examples: ['0x515801d7f9a5ac705e793e85904c9c69b3f1694b465cc8fb6ba3f0298dc82665-0'],
|
|
230
|
+
}),
|
|
231
|
+
),
|
|
232
|
+
logIndex: z
|
|
233
|
+
.number()
|
|
234
|
+
.check(
|
|
235
|
+
z.int(),
|
|
236
|
+
z.nonnegative(),
|
|
237
|
+
z.describe('The transfer event’s log index within the block.'),
|
|
238
|
+
z.meta({ examples: [0] }),
|
|
239
|
+
),
|
|
240
|
+
memo: z
|
|
241
|
+
.optional(z.string())
|
|
242
|
+
.check(
|
|
243
|
+
z.describe(
|
|
244
|
+
'The decoded text memo from the matching `TransferWithMemo` event, when one is present. Returned when you request `include=memo`.',
|
|
245
|
+
),
|
|
246
|
+
z.meta({ examples: ['Payment for invoice 123'] }),
|
|
247
|
+
),
|
|
248
|
+
recipient: Schema.Address.check(
|
|
249
|
+
z.describe('The account address that received the tokens.'),
|
|
250
|
+
),
|
|
251
|
+
sender: Schema.Address.check(z.describe('The account address that sent the tokens.')),
|
|
252
|
+
sourceToken: schema.SourceToken.check(z.describe('The token and amount that moved.')),
|
|
253
|
+
timestamp: z.iso
|
|
254
|
+
.datetime()
|
|
255
|
+
.check(
|
|
256
|
+
z.describe('The block timestamp when this transfer was recorded.'),
|
|
257
|
+
z.meta({ examples: ['2024-01-01T00:00:00Z'] }),
|
|
258
|
+
),
|
|
259
|
+
transactionHash: Schema.Hash.check(
|
|
260
|
+
z.describe('The hash of the transaction that emitted this transfer event.'),
|
|
261
|
+
),
|
|
262
|
+
})
|
|
263
|
+
.check(
|
|
264
|
+
z.describe(
|
|
265
|
+
'One TIP-20 token transfer event, optionally including a payment memo for reconciliation or invoices.',
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
/** Page of TIP-20 token transfers across the chain, ordered by block then log index. */
|
|
270
|
+
export const Response = z
|
|
271
|
+
.object({
|
|
272
|
+
data: z.array(Transfer).check(z.describe('The transfers in this page.')),
|
|
273
|
+
// Response-wide `meta` carries the opt-in capped total count
|
|
274
|
+
// (`include=totalCount`); per-row embeds (`memo`, `token`) attach to
|
|
275
|
+
// each `data` item, not here.
|
|
276
|
+
meta: z
|
|
277
|
+
.optional(Schema.CountMeta)
|
|
278
|
+
.check(z.describe('Page-level resources you requested with `include`.')),
|
|
279
|
+
nextCursor: Schema.NextCursor,
|
|
280
|
+
})
|
|
281
|
+
.check(z.describe('A page of TIP-20 token transfer events across the chain.'))
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Creates transfer handlers. */
|
|
286
|
+
export function transfers() {
|
|
287
|
+
return new Hono<App.Environment>().get(
|
|
288
|
+
'/',
|
|
289
|
+
Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }),
|
|
290
|
+
OpenApi.validate('query', schema.getTransfers.Query, {
|
|
291
|
+
code: 'query_invalid',
|
|
292
|
+
message: 'Invalid query parameters',
|
|
293
|
+
}),
|
|
294
|
+
OpenApi.describeRoute({
|
|
295
|
+
description: 'List token transfer events across Tempo.',
|
|
296
|
+
operationId: 'getTransfers',
|
|
297
|
+
responses: OpenApi.responses({
|
|
298
|
+
errors: { 502: 'The upstream indexer could not serve this request.' },
|
|
299
|
+
success: {
|
|
300
|
+
description: 'A page of token transfer events.',
|
|
301
|
+
schema: schema.getTransfers.Response,
|
|
302
|
+
},
|
|
303
|
+
}),
|
|
304
|
+
summary: 'List transfers',
|
|
305
|
+
tags: ['Transfers'],
|
|
306
|
+
}),
|
|
307
|
+
Cache.response<App.Environment>({
|
|
308
|
+
cacheControl: Cache.policies.feed,
|
|
309
|
+
name: 'tempo-api:transfers:v1',
|
|
310
|
+
key: (c) => Cache.urlKey(c, schema.getTransfers.Query),
|
|
311
|
+
}),
|
|
312
|
+
async (c) => {
|
|
313
|
+
if (Auth.narrowAccess) return Auth.paidAccessError(c)
|
|
314
|
+
if (OpenApi.narrowValidation)
|
|
315
|
+
return OpenApi.validationError(c, {
|
|
316
|
+
code: 'query_invalid',
|
|
317
|
+
message: 'Invalid query parameters',
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
const options = c.req.valid('query')
|
|
321
|
+
const chainId = options.chainId ?? c.get('chainId')
|
|
322
|
+
|
|
323
|
+
// Cursor pages are anchored below the head and effectively immutable,
|
|
324
|
+
// so cache them aggressively; the head page keeps the route's short
|
|
325
|
+
// `feed` default.
|
|
326
|
+
if (options.cursor !== undefined) Cache.setPolicy(c, Cache.policies.immutable)
|
|
327
|
+
|
|
328
|
+
// Filters shared by the page query and the opt-in total count, so the
|
|
329
|
+
// count always matches the page it annotates.
|
|
330
|
+
const predicates = {
|
|
331
|
+
chainId,
|
|
332
|
+
eitherSide: options.address,
|
|
333
|
+
fromBlock: options['blockNumber.from'],
|
|
334
|
+
fromTimestamp: options['timestamp.from'],
|
|
335
|
+
recipient: options.recipient,
|
|
336
|
+
sender: options.sender,
|
|
337
|
+
toBlock: options['blockNumber.to'],
|
|
338
|
+
toTimestamp: options['timestamp.to'],
|
|
339
|
+
token: options.token,
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
try {
|
|
343
|
+
// `totalCount` is opt-in and independent of the page rows, so kick it
|
|
344
|
+
// off concurrently with the page query. It is best-effort: a count
|
|
345
|
+
// failure (e.g. upstream timeout) omits `meta` rather than failing the
|
|
346
|
+
// whole page.
|
|
347
|
+
const countPromise = options.include.includes('totalCount')
|
|
348
|
+
? count(c, {
|
|
349
|
+
...predicates,
|
|
350
|
+
crossToken: options.include.includes('crossToken'),
|
|
351
|
+
timing: 'transfers_count',
|
|
352
|
+
}).catch(() => undefined)
|
|
353
|
+
: undefined
|
|
354
|
+
|
|
355
|
+
const page = await query(c, {
|
|
356
|
+
...predicates,
|
|
357
|
+
cursor: options.cursor,
|
|
358
|
+
limit: options.limit,
|
|
359
|
+
order: options.order,
|
|
360
|
+
page: options.page,
|
|
361
|
+
timing: 'transfers',
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
// Token metadata is opt-in. When requested, resolve each page token's
|
|
365
|
+
// metadata concurrently (sharing one verified-token fetch) and spread
|
|
366
|
+
// it into the row's `sourceToken`. Resolution is best-effort: a token
|
|
367
|
+
// whose metadata is unavailable keeps `sourceToken` as just
|
|
368
|
+
// `address`/`amount` on that row rather than failing the whole page.
|
|
369
|
+
let data: z.output<typeof schema.getTransfers.Transfer>[] = page.data.map((row) => ({
|
|
370
|
+
blockNumber: row.blockNumber,
|
|
371
|
+
id: `${row.transactionHash}-${row.logIndex}`,
|
|
372
|
+
logIndex: row.logIndex,
|
|
373
|
+
recipient: row.recipient,
|
|
374
|
+
sender: row.sender,
|
|
375
|
+
sourceToken: { address: row.address, amount: row.amount },
|
|
376
|
+
timestamp: row.timestamp,
|
|
377
|
+
transactionHash: row.transactionHash,
|
|
378
|
+
}))
|
|
379
|
+
|
|
380
|
+
// Memo/attribution are opt-in: one `TransferWithMemo` lookup bounded
|
|
381
|
+
// to the page's transactions resolves both — the decoded text `memo`,
|
|
382
|
+
// and `attribution` when the memo is a known MPP service fingerprint.
|
|
383
|
+
if (options.include.includes('memo') && data.length > 0) {
|
|
384
|
+
const memoByRow = await memoMap(c, { chainId, rows: data })
|
|
385
|
+
data = data.map((transfer) => {
|
|
386
|
+
const entry = memoByRow.get(
|
|
387
|
+
transferKey(
|
|
388
|
+
transfer.transactionHash,
|
|
389
|
+
transfer.sender,
|
|
390
|
+
transfer.recipient,
|
|
391
|
+
transfer.sourceToken.amount,
|
|
392
|
+
transfer.sourceToken.address,
|
|
393
|
+
),
|
|
394
|
+
)
|
|
395
|
+
if (!entry) return transfer
|
|
396
|
+
return {
|
|
397
|
+
...transfer,
|
|
398
|
+
...(entry.attribution ? { attribution: entry.attribution } : {}),
|
|
399
|
+
...(entry.memo ? { memo: entry.memo } : {}),
|
|
400
|
+
}
|
|
401
|
+
})
|
|
402
|
+
}
|
|
403
|
+
// Cross-token routing is opt-in. One `Transfer` lookup bounded to the
|
|
404
|
+
// page's transactions folds the account server's approve+swap+forward
|
|
405
|
+
// multicall into a single row on the delivery leg: `sourceToken` becomes
|
|
406
|
+
// the token swapped into the DEX and `destinationToken` the token
|
|
407
|
+
// delivered to the recipient. The intermediate DEX legs and the fee leg
|
|
408
|
+
// are dropped from the page. Folding anchors on the delivery leg (whose
|
|
409
|
+
// own event token is the delivered token), so a `token=` filter matches
|
|
410
|
+
// cross-token rows by their destination token, and each transfer still
|
|
411
|
+
// appears exactly once across pages.
|
|
412
|
+
if (options.include.includes('crossToken') && data.length > 0) {
|
|
413
|
+
const { fold, suppress } = await crossTokenMap(c, { chainId, rows: data })
|
|
414
|
+
data = data.flatMap((transfer) => {
|
|
415
|
+
const key = eventKey(transfer.transactionHash, transfer.logIndex)
|
|
416
|
+
const folded = fold.get(key)
|
|
417
|
+
if (folded)
|
|
418
|
+
return [
|
|
419
|
+
{
|
|
420
|
+
...transfer,
|
|
421
|
+
destinationToken: folded.destinationToken,
|
|
422
|
+
recipient: folded.recipient,
|
|
423
|
+
sender: folded.sender,
|
|
424
|
+
sourceToken: folded.sourceToken,
|
|
425
|
+
},
|
|
426
|
+
]
|
|
427
|
+
return suppress.has(key) ? [] : [transfer]
|
|
428
|
+
})
|
|
429
|
+
}
|
|
430
|
+
if (options.include.includes('token') && data.length > 0) {
|
|
431
|
+
const uniqueTokens = Array.from(
|
|
432
|
+
new Set(
|
|
433
|
+
data.flatMap((transfer) => [
|
|
434
|
+
transfer.sourceToken.address,
|
|
435
|
+
...(transfer.destinationToken ? [transfer.destinationToken.address] : []),
|
|
436
|
+
]),
|
|
437
|
+
),
|
|
438
|
+
)
|
|
439
|
+
const snapshot = await VerifiedTokens.snapshot(c, chainId)
|
|
440
|
+
const resolved = await Timing.time(c, 'tokens', () =>
|
|
441
|
+
Promise.all(
|
|
442
|
+
uniqueTokens.map((token) =>
|
|
443
|
+
Tokens.resolveToken(c, {
|
|
444
|
+
address: token,
|
|
445
|
+
chainId,
|
|
446
|
+
snapshot,
|
|
447
|
+
})
|
|
448
|
+
.then((metadata) => [token, metadata] as const)
|
|
449
|
+
.catch(() => [token, undefined] as const),
|
|
450
|
+
),
|
|
451
|
+
),
|
|
452
|
+
)
|
|
453
|
+
const tokensByAddress = new Map(resolved)
|
|
454
|
+
const withToken = <T extends { address: z.output<typeof Schema.TokenAddress> }>(
|
|
455
|
+
entry: T,
|
|
456
|
+
) => {
|
|
457
|
+
const token = tokensByAddress.get(entry.address)
|
|
458
|
+
return token ? { ...entry, ...token } : entry
|
|
459
|
+
}
|
|
460
|
+
data = data.map((transfer) => ({
|
|
461
|
+
...transfer,
|
|
462
|
+
sourceToken: withToken(transfer.sourceToken),
|
|
463
|
+
...(transfer.destinationToken
|
|
464
|
+
? { destinationToken: withToken(transfer.destinationToken) }
|
|
465
|
+
: {}),
|
|
466
|
+
}))
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const meta = countPromise ? await countPromise : undefined
|
|
470
|
+
|
|
471
|
+
return c.json(
|
|
472
|
+
Response.validated(schema.getTransfers.Response, {
|
|
473
|
+
data,
|
|
474
|
+
...(meta ? { meta } : {}),
|
|
475
|
+
nextCursor: page.nextCursor,
|
|
476
|
+
}),
|
|
477
|
+
200,
|
|
478
|
+
)
|
|
479
|
+
} catch (cause) {
|
|
480
|
+
return Response.upstream(c, cause)
|
|
481
|
+
}
|
|
482
|
+
},
|
|
483
|
+
)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Shared TIP-20 `Transfer` page query. Used by `GET /transfers` and the
|
|
488
|
+
* webhook poller. Builds the signature-decoded `Transfer` CTE query, applies
|
|
489
|
+
* opaque keyset pagination on `(block_num, log_idx)`, and decodes rows into a
|
|
490
|
+
* strongly-typed intermediate shape that each caller maps to its own public
|
|
491
|
+
* response.
|
|
492
|
+
*
|
|
493
|
+
* `eitherSide` (the subject address must appear on _either_ side of the
|
|
494
|
+
* transfer) is exposed alongside the one-sided `sender`/`recipient` filters
|
|
495
|
+
* because composing it with the keyset cursor pre-WHERE in SQL is awkward at
|
|
496
|
+
* the call site; the helper takes the knob directly.
|
|
497
|
+
*
|
|
498
|
+
* Memoization lives inside the helper under one canonical `transfers:v1:…`
|
|
499
|
+
* key derived from every filter — so equivalent filter combinations share a
|
|
500
|
+
* single cache entry. Callers supply only a `timing` label for Server-Timing.
|
|
501
|
+
*/
|
|
502
|
+
export function query(c: Context<App.Environment>, options: query.Options) {
|
|
503
|
+
const { chainId, timing, ...rest } = options
|
|
504
|
+
const store = c.get('store')
|
|
505
|
+
const tidx = c.get('getTidx')(chainId)
|
|
506
|
+
const cursor = options.cursor ? Cursor.decode(options.cursor, ['int', 'int']) : undefined
|
|
507
|
+
|
|
508
|
+
return Timing.time(c, timing, () =>
|
|
509
|
+
Store.memoize(
|
|
510
|
+
async () => {
|
|
511
|
+
const page = await scan({ tidx }, { chainId, ...rest })
|
|
512
|
+
return {
|
|
513
|
+
data: page.items.map((item) => item.data),
|
|
514
|
+
nextCursor: page.nextCursor,
|
|
515
|
+
}
|
|
516
|
+
},
|
|
517
|
+
{
|
|
518
|
+
key: `transfers:v1:${chainId}:${options.order}:${options.token ?? ''}:${options.eitherSide ?? ''}:${options.sender ?? ''}:${options.recipient ?? ''}:${options.fromBlock ?? ''}:${options.toBlock ?? ''}:${options.fromTimestamp ?? ''}:${options.toTimestamp ?? ''}:${cursor ? `cursor:${cursor[0]}:${cursor[1]}` : options.page !== undefined && options.page > 1 ? `page:${options.page}` : 'head'}:${options.limit}`,
|
|
519
|
+
store,
|
|
520
|
+
ttl: Ttl.seconds(15),
|
|
521
|
+
},
|
|
522
|
+
),
|
|
523
|
+
)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
export declare namespace query {
|
|
527
|
+
/** Options for the shared TIP-20 `Transfer` page query. */
|
|
528
|
+
type Options = {
|
|
529
|
+
chainId: z.output<typeof Schema.ChainId>
|
|
530
|
+
/** Opaque keyset cursor anchoring the page; omit for the head page. */
|
|
531
|
+
cursor?: string | undefined
|
|
532
|
+
/** Restrict to rows where this address appears on either side. */
|
|
533
|
+
eitherSide?: z.output<typeof Schema.Address> | undefined
|
|
534
|
+
fromBlock?: number | undefined
|
|
535
|
+
fromTimestamp?: string | undefined
|
|
536
|
+
limit: number
|
|
537
|
+
/** 1-indexed page number (positional pagination; exclusive with `cursor`). */
|
|
538
|
+
page?: number | undefined
|
|
539
|
+
order: 'asc' | 'desc'
|
|
540
|
+
recipient?: z.output<typeof Schema.Address> | undefined
|
|
541
|
+
sender?: z.output<typeof Schema.Address> | undefined
|
|
542
|
+
/** Server-Timing label for the caller's surface. */
|
|
543
|
+
timing: string
|
|
544
|
+
toBlock?: number | undefined
|
|
545
|
+
toTimestamp?: string | undefined
|
|
546
|
+
/** Restrict to a single TIP-20 token contract. */
|
|
547
|
+
token?: z.output<typeof Schema.Address> | undefined
|
|
548
|
+
}
|
|
549
|
+
/** A decoded `Transfer` row before per-surface shaping. */
|
|
550
|
+
type Row = {
|
|
551
|
+
/** Token contract that emitted the event. */
|
|
552
|
+
address: z.output<typeof Schema.Address>
|
|
553
|
+
amount: string
|
|
554
|
+
blockNumber: number
|
|
555
|
+
logIndex: number
|
|
556
|
+
recipient: z.output<typeof Schema.Address>
|
|
557
|
+
sender: z.output<typeof Schema.Address>
|
|
558
|
+
timestamp: string
|
|
559
|
+
transactionHash: z.output<typeof Schema.Hash>
|
|
560
|
+
}
|
|
561
|
+
type Page = {
|
|
562
|
+
data: readonly Row[]
|
|
563
|
+
nextCursor: string | null
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Context-free core of {@link query}: builds the signature-decoded `Transfer`
|
|
569
|
+
* CTE query, applies keyset pagination on `(block_num, log_idx)`, and decodes
|
|
570
|
+
* rows. Used by the Hono routes (via {@link query}, which adds Server-Timing +
|
|
571
|
+
* memoization) and by the webhook poller (uncached, ascending, per-row cursors).
|
|
572
|
+
*/
|
|
573
|
+
export async function scan(deps: scan.Deps, options: scan.Options): Promise<scan.Page> {
|
|
574
|
+
const { chainId, limit, order } = options
|
|
575
|
+
const { tidx } = deps
|
|
576
|
+
const direction = order === 'asc' ? 'ASC' : 'DESC'
|
|
577
|
+
// Keyset pagination: anchor the page below the previous row's `(block, log_idx)`
|
|
578
|
+
// position instead of a numeric offset, so rows arriving at the head can't
|
|
579
|
+
// shift items across pages. A malformed cursor falls back to the head page.
|
|
580
|
+
const cursor = options.cursor ? Cursor.decode(options.cursor, ['int', 'int']) : undefined
|
|
581
|
+
// Bounded positional lane (exclusive with `cursor` at the schema): page 1 is
|
|
582
|
+
// the head page, so it shares the head's SQL and cache entries; deeper pages
|
|
583
|
+
// translate to a SQL OFFSET of `(page - 1) * limit` rows.
|
|
584
|
+
const offset =
|
|
585
|
+
options.page !== undefined && options.page > 1 ? (options.page - 1) * limit : undefined
|
|
586
|
+
|
|
587
|
+
// Transfer events filtered by any combination of contract / subject /
|
|
588
|
+
// sender / recipient / block / timestamp. Ordered by block then log
|
|
589
|
+
// index so transfers within a block stay stable. The keyset cursor clause is
|
|
590
|
+
// page-only and added on top of the shared filters below.
|
|
591
|
+
const filters = filterClauses(options)
|
|
592
|
+
if (cursor !== undefined)
|
|
593
|
+
filters.push(
|
|
594
|
+
Cursor.keyset([
|
|
595
|
+
{ literal: Cursor.literal(cursor[0]!, 'int'), name: 'block_num', order },
|
|
596
|
+
{ literal: Cursor.literal(cursor[1]!, 'int'), name: 'log_idx', order },
|
|
597
|
+
]),
|
|
598
|
+
)
|
|
599
|
+
const where = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''
|
|
600
|
+
|
|
601
|
+
// Fetch one extra row to detect `hasMore` without a separate count query. The
|
|
602
|
+
// global minimum page size (`Schema.minLimit`) keeps this at or above the
|
|
603
|
+
// indexer's floor for signature-decoded `Transfer` queries.
|
|
604
|
+
const result = await tidx.fetch({
|
|
605
|
+
chainId,
|
|
606
|
+
query: `
|
|
607
|
+
SELECT "from", "to", address, value, tx_hash, block_num, log_idx, block_timestamp
|
|
608
|
+
FROM Transfer
|
|
609
|
+
${where}
|
|
610
|
+
ORDER BY block_num ${direction}, log_idx ${direction}
|
|
611
|
+
LIMIT ${limit + 1}${offset !== undefined ? ` OFFSET ${offset}` : ''}
|
|
612
|
+
`,
|
|
613
|
+
signatures: [signature],
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
// The next page anchors below the last fetched row's `(block, log_idx)`.
|
|
617
|
+
const page = Cursor.paginate({
|
|
618
|
+
rows: result.rows,
|
|
619
|
+
limit,
|
|
620
|
+
key: (row) => {
|
|
621
|
+
const block = Value.toNumber(row['block_num'])
|
|
622
|
+
const index = Value.toNumber(row['log_idx'])
|
|
623
|
+
return block !== undefined && index !== undefined ? [block, index] : undefined
|
|
624
|
+
},
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
const items: scan.Item[] = []
|
|
628
|
+
for (const row of page.rows) {
|
|
629
|
+
const tokenAddress = Schema.Address.safeParse(row['address'])
|
|
630
|
+
const from = Schema.Address.safeParse(row['from'])
|
|
631
|
+
const to = Schema.Address.safeParse(row['to'])
|
|
632
|
+
const transactionHash = Schema.Hash.safeParse(row['tx_hash'])
|
|
633
|
+
const amount = Value.toIntegerString(row['value'])
|
|
634
|
+
const blockNumber = Value.toNumber(row['block_num'])
|
|
635
|
+
const logIndex = Value.toNumber(row['log_idx'])
|
|
636
|
+
const timestamp = Value.toIsoDateTime(row['block_timestamp'])
|
|
637
|
+
if (
|
|
638
|
+
tokenAddress.success &&
|
|
639
|
+
from.success &&
|
|
640
|
+
to.success &&
|
|
641
|
+
transactionHash.success &&
|
|
642
|
+
amount !== undefined &&
|
|
643
|
+
blockNumber !== undefined &&
|
|
644
|
+
logIndex !== undefined &&
|
|
645
|
+
timestamp !== undefined
|
|
646
|
+
)
|
|
647
|
+
items.push({
|
|
648
|
+
cursor: [blockNumber, logIndex],
|
|
649
|
+
data: {
|
|
650
|
+
address: tokenAddress.data,
|
|
651
|
+
amount,
|
|
652
|
+
blockNumber,
|
|
653
|
+
logIndex,
|
|
654
|
+
recipient: to.data,
|
|
655
|
+
sender: from.data,
|
|
656
|
+
timestamp,
|
|
657
|
+
transactionHash: transactionHash.data,
|
|
658
|
+
},
|
|
659
|
+
})
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
return { hasMore: page.hasMore, items, limit, nextCursor: page.nextCursor }
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
export declare namespace scan {
|
|
666
|
+
/** Dependencies for {@link scan}. */
|
|
667
|
+
type Deps = {
|
|
668
|
+
/** TIDX query client for the target chain. */
|
|
669
|
+
tidx: Tidx.Client
|
|
670
|
+
}
|
|
671
|
+
/** Options for {@link scan} (everything {@link query.Options} has except `timing`). */
|
|
672
|
+
type Options = Omit<query.Options, 'timing'>
|
|
673
|
+
/** A decoded `Transfer` row plus its `(block_num, log_idx)` cursor. */
|
|
674
|
+
type Item = {
|
|
675
|
+
/** Keyset position of the row. */
|
|
676
|
+
cursor: readonly [blockNumber: number, logIndex: number]
|
|
677
|
+
/** The decoded row before per-surface shaping. */
|
|
678
|
+
data: query.Row
|
|
679
|
+
}
|
|
680
|
+
/** A page of scanned `Transfer` rows. */
|
|
681
|
+
type Page = {
|
|
682
|
+
hasMore: boolean
|
|
683
|
+
items: readonly Item[]
|
|
684
|
+
limit: number
|
|
685
|
+
nextCursor: string | null
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Builds the shared `WHERE` filter clauses for the `Transfer` feed: contract /
|
|
691
|
+
* subject / sender / recipient / block / timestamp predicates. Shared verbatim
|
|
692
|
+
* by the page query ({@link scan}) and the capped count ({@link countScan}) so a
|
|
693
|
+
* `totalCount` always matches the page it annotates; the keyset cursor clause is
|
|
694
|
+
* page-only and added by {@link scan} on top of these.
|
|
695
|
+
*/
|
|
696
|
+
function filterClauses(options: filterClauses.Options): string[] {
|
|
697
|
+
const { eitherSide, fromBlock, fromTimestamp, recipient, sender, toBlock, toTimestamp, token } =
|
|
698
|
+
options
|
|
699
|
+
const filters: string[] = []
|
|
700
|
+
if (token !== undefined) filters.push(`address = '${token}'`)
|
|
701
|
+
if (eitherSide !== undefined) filters.push(`("from" = '${eitherSide}' OR "to" = '${eitherSide}')`)
|
|
702
|
+
if (sender !== undefined) filters.push(`"from" = '${sender}'`)
|
|
703
|
+
if (recipient !== undefined) filters.push(`"to" = '${recipient}'`)
|
|
704
|
+
if (fromBlock !== undefined) filters.push(`block_num >= ${fromBlock}`)
|
|
705
|
+
if (toBlock !== undefined) filters.push(`block_num <= ${toBlock}`)
|
|
706
|
+
if (fromTimestamp !== undefined) filters.push(`block_timestamp >= '${fromTimestamp}'`)
|
|
707
|
+
if (toTimestamp !== undefined) filters.push(`block_timestamp <= '${toTimestamp}'`)
|
|
708
|
+
return filters
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
declare namespace filterClauses {
|
|
712
|
+
/** The non-pagination filter fields of {@link scan.Options}. */
|
|
713
|
+
type Options = Pick<
|
|
714
|
+
scan.Options,
|
|
715
|
+
| 'eitherSide'
|
|
716
|
+
| 'fromBlock'
|
|
717
|
+
| 'fromTimestamp'
|
|
718
|
+
| 'recipient'
|
|
719
|
+
| 'sender'
|
|
720
|
+
| 'toBlock'
|
|
721
|
+
| 'toTimestamp'
|
|
722
|
+
| 'token'
|
|
723
|
+
>
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Capped total-row count for the `Transfer` feed, sharing {@link query}'s
|
|
728
|
+
* filters (minus pagination) so the count matches the page it annotates. Timed
|
|
729
|
+
* and memoized like {@link query}; the result feeds `meta.totalCount`/`totalCountCapped`
|
|
730
|
+
* when a caller opts in via `include=totalCount`.
|
|
731
|
+
*/
|
|
732
|
+
export function count(c: Context<App.Environment>, options: count.Options): Promise<count.Result> {
|
|
733
|
+
const { chainId, timing, ...rest } = options
|
|
734
|
+
const store = c.get('store')
|
|
735
|
+
const tidx = c.get('getTidx')(chainId)
|
|
736
|
+
|
|
737
|
+
return Timing.time(c, timing, () =>
|
|
738
|
+
Store.memoize(() => countScan({ tidx }, { chainId, ...rest }), {
|
|
739
|
+
key: `transfers:count:v1:${chainId}:${rest.crossToken ? 'fold' : 'raw'}:${rest.token ?? ''}:${rest.eitherSide ?? ''}:${rest.sender ?? ''}:${rest.recipient ?? ''}:${rest.fromBlock ?? ''}:${rest.toBlock ?? ''}:${rest.fromTimestamp ?? ''}:${rest.toTimestamp ?? ''}`,
|
|
740
|
+
store,
|
|
741
|
+
ttl: Ttl.seconds(15),
|
|
742
|
+
}),
|
|
743
|
+
)
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
export declare namespace count {
|
|
747
|
+
/** Options for the capped `Transfer` count: {@link query}'s filters plus `timing`. */
|
|
748
|
+
type Options = Pick<
|
|
749
|
+
query.Options,
|
|
750
|
+
| 'chainId'
|
|
751
|
+
| 'eitherSide'
|
|
752
|
+
| 'fromBlock'
|
|
753
|
+
| 'fromTimestamp'
|
|
754
|
+
| 'recipient'
|
|
755
|
+
| 'sender'
|
|
756
|
+
| 'timing'
|
|
757
|
+
| 'toBlock'
|
|
758
|
+
| 'toTimestamp'
|
|
759
|
+
| 'token'
|
|
760
|
+
> & {
|
|
761
|
+
/** Count folded cross-token transfers instead of raw `Transfer` logs. */
|
|
762
|
+
crossToken?: boolean | undefined
|
|
763
|
+
}
|
|
764
|
+
/** A capped count result: `totalCountCapped` is `true` when the count hit `Schema.countCap`. */
|
|
765
|
+
type Result = { totalCountCapped: boolean; totalCount: number }
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Context-free core of {@link count}: runs a capped `count(*)` over the
|
|
770
|
+
* signature-decoded `Transfer` CTE under the shared {@link filterClauses}. The
|
|
771
|
+
* inner `LIMIT Schema.countCap` bounds the scan so the aggregate can't exceed
|
|
772
|
+
* the indexer's execution budget on the 10⁹-plus row table (an unbounded count
|
|
773
|
+
* there blows the 5s `max_execution_time`); a result equal to the cap is a
|
|
774
|
+
* lower bound, surfaced as `totalCountCapped: true`.
|
|
775
|
+
*/
|
|
776
|
+
export async function countScan(
|
|
777
|
+
deps: scan.Deps,
|
|
778
|
+
options: countScan.Options,
|
|
779
|
+
): Promise<count.Result> {
|
|
780
|
+
const { chainId } = options
|
|
781
|
+
const { tidx } = deps
|
|
782
|
+
const filters = filterClauses(options)
|
|
783
|
+
const where = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''
|
|
784
|
+
|
|
785
|
+
// Folded count: cross-token transfers collapse several `Transfer` legs into
|
|
786
|
+
// one row, so a raw `count(*)` would overcount. Scan the capped `Transfer`
|
|
787
|
+
// rows, classify each transaction, and subtract the folded-away legs. Bounded
|
|
788
|
+
// by `countCap` like the raw count, so it stays within the indexer's budget.
|
|
789
|
+
if (options.crossToken) {
|
|
790
|
+
const result = await tidx.fetch({
|
|
791
|
+
chainId,
|
|
792
|
+
query: `
|
|
793
|
+
SELECT tx_hash, log_idx, "from", "to", address, value
|
|
794
|
+
FROM Transfer
|
|
795
|
+
${where}
|
|
796
|
+
ORDER BY block_num, log_idx
|
|
797
|
+
LIMIT ${Schema.countCap}
|
|
798
|
+
`,
|
|
799
|
+
signatures: [signature],
|
|
800
|
+
})
|
|
801
|
+
const rawCount = result.rows.length
|
|
802
|
+
let suppressed = 0
|
|
803
|
+
for (const legs of legsByTransaction(result.rows).values()) {
|
|
804
|
+
const bundle = classifyCrossToken(legs)
|
|
805
|
+
if (bundle) suppressed += bundle.suppressLogIndexes.length
|
|
806
|
+
}
|
|
807
|
+
return { totalCountCapped: rawCount >= Schema.countCap, totalCount: rawCount - suppressed }
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const result = await tidx.fetch({
|
|
811
|
+
chainId,
|
|
812
|
+
query: `
|
|
813
|
+
SELECT count(*) AS total
|
|
814
|
+
FROM (
|
|
815
|
+
SELECT 1
|
|
816
|
+
FROM Transfer
|
|
817
|
+
${where}
|
|
818
|
+
LIMIT ${Schema.countCap}
|
|
819
|
+
) AS capped
|
|
820
|
+
`,
|
|
821
|
+
signatures: [signature],
|
|
822
|
+
})
|
|
823
|
+
|
|
824
|
+
const totalCount = Value.toNumber(result.rows[0]?.['total']) ?? 0
|
|
825
|
+
return { totalCountCapped: totalCount >= Schema.countCap, totalCount }
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
export declare namespace countScan {
|
|
829
|
+
/** Options for {@link countScan}: the shared filters plus the target chain. */
|
|
830
|
+
type Options = filterClauses.Options &
|
|
831
|
+
Pick<scan.Options, 'chainId'> & {
|
|
832
|
+
/** Count folded cross-token transfers instead of raw `Transfer` logs. */
|
|
833
|
+
crossToken?: boolean | undefined
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/** `'tx1', 'tx2', …` SQL `IN` list of the page's distinct transaction hashes. */
|
|
838
|
+
function txInList(rows: memoMap.Options['rows']): string {
|
|
839
|
+
return [...new Set(rows.map((row) => row.transactionHash.toLowerCase()))]
|
|
840
|
+
.map((hash) => `'${hash}'`)
|
|
841
|
+
.join(', ')
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* `block_num BETWEEN min AND max` bound covering the page's rows. Event-CTE
|
|
846
|
+
* predicates on `tx_hash` cannot prune `logs` (sorted by block position), so
|
|
847
|
+
* a bare `tx_hash IN (…)` scans to genesis; pairing it with the page's block
|
|
848
|
+
* range keeps the lookup a bounded range read. Callers only invoke the memo
|
|
849
|
+
* lookup for non-empty pages.
|
|
850
|
+
*/
|
|
851
|
+
function blockBound(rows: memoMap.Options['rows']): string {
|
|
852
|
+
const blocks = rows.map((row) => row.blockNumber)
|
|
853
|
+
return `block_num BETWEEN ${Math.min(...blocks)} AND ${Math.max(...blocks)}`
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Resolves the memo of each of a page's transfers from their matching
|
|
858
|
+
* `TransferWithMemo` events, matched back to rows by {@link transferKey}: the
|
|
859
|
+
* decoded text `memo`, and `attribution` (the resolved MPP service name) when
|
|
860
|
+
* the memo is a known service fingerprint. The fingerprint directory is
|
|
861
|
+
* best-effort: a fetch failure leaves a memo unattributed rather than failing
|
|
862
|
+
* the page.
|
|
863
|
+
*/
|
|
864
|
+
function memoMap(
|
|
865
|
+
c: Context<App.Environment>,
|
|
866
|
+
options: memoMap.Options,
|
|
867
|
+
): Promise<Map<string, { attribution?: string | undefined; memo?: string | undefined }>> {
|
|
868
|
+
const { chainId, rows } = options
|
|
869
|
+
const tidx = c.get('getTidx')(chainId)
|
|
870
|
+
|
|
871
|
+
return Timing.time(c, 'transfers_memo', async () => {
|
|
872
|
+
const [memos, fingerprintMap] = await Promise.all([
|
|
873
|
+
tidx.fetch({
|
|
874
|
+
chainId,
|
|
875
|
+
query: `
|
|
876
|
+
SELECT tx_hash, "from", "to", amount, address, memo
|
|
877
|
+
FROM TransferWithMemo
|
|
878
|
+
WHERE ${blockBound(rows)} AND tx_hash IN (${txInList(rows)})
|
|
879
|
+
`,
|
|
880
|
+
signatures: [memoSignature],
|
|
881
|
+
}),
|
|
882
|
+
Mpp.fingerprintMap({ store: c.get('store') }).catch(() => ({})),
|
|
883
|
+
])
|
|
884
|
+
|
|
885
|
+
const memoByRow = new Map<
|
|
886
|
+
string,
|
|
887
|
+
{ attribution?: string | undefined; memo?: string | undefined }
|
|
888
|
+
>()
|
|
889
|
+
for (const row of memos.rows) {
|
|
890
|
+
const rawMemo = Value.toText(row['memo']) as Hex.Hex
|
|
891
|
+
const attribution = Mpp.resolve({ fingerprintMap, memo: rawMemo })
|
|
892
|
+
const memo = memoToString(rawMemo)
|
|
893
|
+
const amount = Value.toIntegerString(row['amount'])
|
|
894
|
+
const txHash = Value.toText(row['tx_hash'])
|
|
895
|
+
const from = Value.toText(row['from'])
|
|
896
|
+
const to = Value.toText(row['to'])
|
|
897
|
+
const token = Value.toText(row['address'])
|
|
898
|
+
if ((attribution || memo) && amount && txHash && from && to && token)
|
|
899
|
+
memoByRow.set(transferKey(txHash, from, to, amount, token), { attribution, memo })
|
|
900
|
+
}
|
|
901
|
+
return memoByRow
|
|
902
|
+
})
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
declare namespace memoMap {
|
|
906
|
+
/** Options for the per-page memo lookup. */
|
|
907
|
+
type Options = {
|
|
908
|
+
/** Source chain id. */
|
|
909
|
+
chainId: z.output<typeof Schema.ChainId>
|
|
910
|
+
/** The page rows; only their `transactionHash` and `blockNumber` are read. */
|
|
911
|
+
rows: readonly { blockNumber: number; transactionHash: string }[]
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** A token's contract address and moved amount, matching `SourceToken`'s core. */
|
|
916
|
+
type CrossToken = { address: z.output<typeof Schema.Address>; amount: string }
|
|
917
|
+
|
|
918
|
+
/** A decoded `Transfer` leg used for cross-token classification. */
|
|
919
|
+
type Leg = {
|
|
920
|
+
address: z.output<typeof Schema.Address>
|
|
921
|
+
amount: string
|
|
922
|
+
from: z.output<typeof Schema.Address>
|
|
923
|
+
logIndex: number
|
|
924
|
+
to: z.output<typeof Schema.Address>
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/** The folded shape of a detected cross-token transfer, anchored on its delivery leg. */
|
|
928
|
+
type CrossTokenBundle = {
|
|
929
|
+
/** Log index of the delivery leg the transfer folds onto. */
|
|
930
|
+
deliveryLogIndex: number
|
|
931
|
+
/** The delivered token and amount (the delivery leg's own event token). */
|
|
932
|
+
destinationToken: CrossToken
|
|
933
|
+
/** The recipient the swapped-out token was forwarded to. */
|
|
934
|
+
recipient: z.output<typeof Schema.Address>
|
|
935
|
+
/** The swapper that funded the transfer through the DEX. */
|
|
936
|
+
sender: z.output<typeof Schema.Address>
|
|
937
|
+
/** The token and amount swapped into the DEX. */
|
|
938
|
+
sourceToken: CrossToken
|
|
939
|
+
/** Log indexes of the legs folded away (the two DEX legs and any fee leg). */
|
|
940
|
+
suppressLogIndexes: number[]
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Classifies a single transaction's `Transfer` legs as a cross-token transfer,
|
|
945
|
+
* or returns `undefined`. A cross-token transfer is the account server's
|
|
946
|
+
* approve+swap+forward multicall: the source token is sent into the DEX, the
|
|
947
|
+
* swapped token comes back to the swapper, and that swapped token is forwarded
|
|
948
|
+
* to a different recipient in the same transaction. The transfer folds onto the
|
|
949
|
+
* delivery (forwarding) leg; the two DEX legs and any fee leg fold away. A swap
|
|
950
|
+
* whose output stays with the swapper (no onward leg) is not a cross-token
|
|
951
|
+
* transfer and is left untouched.
|
|
952
|
+
*/
|
|
953
|
+
function classifyCrossToken(legs: readonly Leg[]): CrossTokenBundle | undefined {
|
|
954
|
+
const toDex = legs.find((leg) => Address.isEqual(leg.to, Addresses.stablecoinDex))
|
|
955
|
+
const fromDex = legs.find((leg) => Address.isEqual(leg.from, Addresses.stablecoinDex))
|
|
956
|
+
if (!toDex || !fromDex) return undefined
|
|
957
|
+
const swapper = fromDex.to
|
|
958
|
+
const destTokenAddress = fromDex.address
|
|
959
|
+
const delivery = legs.find(
|
|
960
|
+
(leg) =>
|
|
961
|
+
Address.isEqual(leg.address, destTokenAddress) &&
|
|
962
|
+
Address.isEqual(leg.from, swapper) &&
|
|
963
|
+
!Address.isEqual(leg.to, Addresses.stablecoinDex) &&
|
|
964
|
+
!Address.isEqual(leg.to, swapper) &&
|
|
965
|
+
!Address.isEqual(leg.to, Addresses.feeManager),
|
|
966
|
+
)
|
|
967
|
+
// Require a token change so plain same-token DEX activity is not folded.
|
|
968
|
+
if (!delivery || Address.isEqual(toDex.address, delivery.address)) return undefined
|
|
969
|
+
const suppressLogIndexes = legs
|
|
970
|
+
.filter(
|
|
971
|
+
(leg) =>
|
|
972
|
+
leg.logIndex !== delivery.logIndex &&
|
|
973
|
+
(Address.isEqual(leg.to, Addresses.stablecoinDex) ||
|
|
974
|
+
Address.isEqual(leg.from, Addresses.stablecoinDex) ||
|
|
975
|
+
Address.isEqual(leg.to, Addresses.feeManager)),
|
|
976
|
+
)
|
|
977
|
+
.map((leg) => leg.logIndex)
|
|
978
|
+
return {
|
|
979
|
+
deliveryLogIndex: delivery.logIndex,
|
|
980
|
+
destinationToken: { address: delivery.address, amount: delivery.amount },
|
|
981
|
+
recipient: delivery.to,
|
|
982
|
+
sender: swapper,
|
|
983
|
+
sourceToken: { address: toDex.address, amount: toDex.amount },
|
|
984
|
+
suppressLogIndexes,
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/** Groups decoded `Transfer` rows by lowercased transaction hash. */
|
|
989
|
+
function legsByTransaction(rows: readonly Record<string, unknown>[]): Map<string, Leg[]> {
|
|
990
|
+
const legsByTx = new Map<string, Leg[]>()
|
|
991
|
+
for (const row of rows) {
|
|
992
|
+
const txHash = Value.toText(row['tx_hash'])
|
|
993
|
+
const from = Schema.Address.safeParse(row['from'])
|
|
994
|
+
const to = Schema.Address.safeParse(row['to'])
|
|
995
|
+
const address = Schema.Address.safeParse(row['address'])
|
|
996
|
+
const amount = Value.toIntegerString(row['value'])
|
|
997
|
+
const logIndex = Value.toNumber(row['log_idx'])
|
|
998
|
+
if (
|
|
999
|
+
txHash &&
|
|
1000
|
+
from.success &&
|
|
1001
|
+
to.success &&
|
|
1002
|
+
address.success &&
|
|
1003
|
+
amount !== undefined &&
|
|
1004
|
+
logIndex !== undefined
|
|
1005
|
+
) {
|
|
1006
|
+
const legs = legsByTx.get(txHash.toLowerCase()) ?? []
|
|
1007
|
+
legs.push({ address: address.data, amount, from: from.data, logIndex, to: to.data })
|
|
1008
|
+
legsByTx.set(txHash.toLowerCase(), legs)
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
return legsByTx
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* Detects cross-token transfers among a page's transactions and returns the
|
|
1016
|
+
* folding plan: `fold` maps each delivery leg (by `${txHash}-${logIndex}`) to
|
|
1017
|
+
* its folded row (swapper, recipient, swapped-in `sourceToken`, delivered
|
|
1018
|
+
* `destinationToken`), and `suppress` is the set of the other bundle legs (the
|
|
1019
|
+
* two DEX legs and any fee leg) to drop from the page.
|
|
1020
|
+
*
|
|
1021
|
+
* Bounded to the page's transactions and block range like {@link memoMap}, so it
|
|
1022
|
+
* discovers the sibling DEX legs even when they fall outside the current page.
|
|
1023
|
+
*
|
|
1024
|
+
* TODO(https://tips.sh/1072): once TIP-1072 lands, cross-token conversions emit
|
|
1025
|
+
* a single self-describing `Convert(user, sourceToken, destinationToken,
|
|
1026
|
+
* backingToken, amount)` event. Replace this extra `Transfer` lookup and the
|
|
1027
|
+
* multi-leg {@link classifyCrossToken} heuristic with a direct `Convert` decode
|
|
1028
|
+
* (and drop the folded-count scan in {@link countScan}).
|
|
1029
|
+
*/
|
|
1030
|
+
function crossTokenMap(
|
|
1031
|
+
c: Context<App.Environment>,
|
|
1032
|
+
options: memoMap.Options,
|
|
1033
|
+
): Promise<{
|
|
1034
|
+
fold: Map<
|
|
1035
|
+
string,
|
|
1036
|
+
{
|
|
1037
|
+
destinationToken: CrossToken
|
|
1038
|
+
recipient: z.output<typeof Schema.Address>
|
|
1039
|
+
sender: z.output<typeof Schema.Address>
|
|
1040
|
+
sourceToken: CrossToken
|
|
1041
|
+
}
|
|
1042
|
+
>
|
|
1043
|
+
suppress: Set<string>
|
|
1044
|
+
}> {
|
|
1045
|
+
const { chainId, rows } = options
|
|
1046
|
+
const tidx = c.get('getTidx')(chainId)
|
|
1047
|
+
|
|
1048
|
+
return Timing.time(c, 'transfers_cross_token', async () => {
|
|
1049
|
+
const result = await tidx.fetch({
|
|
1050
|
+
chainId,
|
|
1051
|
+
query: `
|
|
1052
|
+
SELECT tx_hash, log_idx, "from", "to", address, value
|
|
1053
|
+
FROM Transfer
|
|
1054
|
+
WHERE ${blockBound(rows)} AND tx_hash IN (${txInList(rows)})
|
|
1055
|
+
ORDER BY tx_hash, log_idx
|
|
1056
|
+
`,
|
|
1057
|
+
signatures: [signature],
|
|
1058
|
+
})
|
|
1059
|
+
|
|
1060
|
+
const fold = new Map<
|
|
1061
|
+
string,
|
|
1062
|
+
{
|
|
1063
|
+
destinationToken: CrossToken
|
|
1064
|
+
recipient: z.output<typeof Schema.Address>
|
|
1065
|
+
sender: z.output<typeof Schema.Address>
|
|
1066
|
+
sourceToken: CrossToken
|
|
1067
|
+
}
|
|
1068
|
+
>()
|
|
1069
|
+
const suppress = new Set<string>()
|
|
1070
|
+
for (const [txHash, legs] of legsByTransaction(result.rows)) {
|
|
1071
|
+
const bundle = classifyCrossToken(legs)
|
|
1072
|
+
if (!bundle) continue
|
|
1073
|
+
fold.set(eventKey(txHash, bundle.deliveryLogIndex), {
|
|
1074
|
+
destinationToken: bundle.destinationToken,
|
|
1075
|
+
recipient: bundle.recipient,
|
|
1076
|
+
sender: bundle.sender,
|
|
1077
|
+
sourceToken: bundle.sourceToken,
|
|
1078
|
+
})
|
|
1079
|
+
for (const logIndex of bundle.suppressLogIndexes) suppress.add(eventKey(txHash, logIndex))
|
|
1080
|
+
}
|
|
1081
|
+
return { fold, suppress }
|
|
1082
|
+
})
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/** Composite `${transactionHash}-${logIndex}` key for a single transfer event. */
|
|
1086
|
+
function eventKey(transactionHash: string, logIndex: number): string {
|
|
1087
|
+
return `${transactionHash}-${logIndex}`.toLowerCase()
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* Attempts to decode a bytes32 memo into a human-readable string. MPP
|
|
1092
|
+
* attribution memos encode a service fingerprint, not text — they are
|
|
1093
|
+
* suppressed from the display memo (and resolved to `attribution` instead).
|
|
1094
|
+
* Shared with the activity feed's transfer entries.
|
|
1095
|
+
*/
|
|
1096
|
+
export function memoToString(memo: Hex.Hex): string | undefined {
|
|
1097
|
+
if (Mpp.isAttributionMemo(memo)) return undefined
|
|
1098
|
+
try {
|
|
1099
|
+
const trimmed = Hex.trimLeft(memo)
|
|
1100
|
+
if (trimmed === '0x' || trimmed === '0x0') return undefined
|
|
1101
|
+
return Hex.toString(trimmed)
|
|
1102
|
+
} catch {
|
|
1103
|
+
return undefined
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/** Composite match key shared by the `Transfer` row and its `TransferWithMemo`. */
|
|
1108
|
+
function transferKey(
|
|
1109
|
+
txHash: string,
|
|
1110
|
+
from: string,
|
|
1111
|
+
to: string,
|
|
1112
|
+
amount: string,
|
|
1113
|
+
token: string,
|
|
1114
|
+
): string {
|
|
1115
|
+
return `${txHash}:${from}:${to}:${amount}:${token}`.toLowerCase()
|
|
1116
|
+
}
|