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