tapimo 0.9.0 → 0.10.1
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/dist/ApiKey.d.ts +2 -1
- package/dist/ApiKey.d.ts.map +1 -1
- package/dist/ApiKeys.d.ts +4 -2
- package/dist/ApiKeys.d.ts.map +1 -1
- package/dist/ApiKeys.js +26 -1
- package/dist/ApiKeys.js.map +1 -1
- package/dist/App.d.ts +4292 -3708
- package/dist/App.d.ts.map +1 -1
- package/dist/App.js +1 -0
- package/dist/App.js.map +1 -1
- package/dist/Webhooks.d.ts +21 -20
- package/dist/Webhooks.d.ts.map +1 -1
- package/dist/Webhooks.js +66 -23
- package/dist/Webhooks.js.map +1 -1
- package/dist/admin/App.d.ts +91 -84
- package/dist/admin/App.d.ts.map +1 -1
- package/dist/admin/apps/api-keys.d.ts +26 -17
- package/dist/admin/apps/api-keys.d.ts.map +1 -1
- package/dist/admin/apps/api-keys.js +6 -0
- package/dist/admin/apps/api-keys.js.map +1 -1
- package/dist/admin/apps/organizations.d.ts +4 -2
- package/dist/admin/apps/organizations.d.ts.map +1 -1
- package/dist/admin/apps/verified-tokens.d.ts +168 -168
- package/dist/admin/ui.generated.d.ts +1 -1
- package/dist/admin/ui.generated.js +69 -69
- package/dist/admin/ui.generated.js.map +1 -1
- package/dist/analytics/Analytics.d.ts.map +1 -1
- package/dist/analytics/Analytics.js +26 -0
- package/dist/analytics/Analytics.js.map +1 -1
- package/dist/apps/Zones.d.ts +4280 -84
- package/dist/apps/Zones.d.ts.map +1 -1
- package/dist/apps/Zones.js +30 -10
- package/dist/apps/Zones.js.map +1 -1
- package/dist/apps/data/App.d.ts +5799 -4929
- package/dist/apps/data/App.d.ts.map +1 -1
- package/dist/apps/data/FxOracle.d.ts +4 -5
- package/dist/apps/data/FxOracle.d.ts.map +1 -1
- package/dist/apps/data/FxOracle.js +19 -4
- package/dist/apps/data/FxOracle.js.map +1 -1
- package/dist/apps/data/routes/activities.d.ts +3391 -492
- package/dist/apps/data/routes/activities.d.ts.map +1 -1
- package/dist/apps/data/routes/activities.js +372 -29
- package/dist/apps/data/routes/activities.js.map +1 -1
- package/dist/apps/data/routes/addresses.d.ts +853 -339
- package/dist/apps/data/routes/addresses.d.ts.map +1 -1
- package/dist/apps/data/routes/balances.d.ts +10 -10
- package/dist/apps/data/routes/balances.d.ts.map +1 -1
- package/dist/apps/data/routes/balances.js +145 -83
- package/dist/apps/data/routes/balances.js.map +1 -1
- package/dist/apps/data/routes/blocks.d.ts +123 -123
- package/dist/apps/data/routes/coingecko.d.ts +169 -169
- package/dist/apps/data/routes/earn.d.ts +455 -1
- package/dist/apps/data/routes/earn.d.ts.map +1 -1
- package/dist/apps/data/routes/earn.js +369 -45
- package/dist/apps/data/routes/earn.js.map +1 -1
- package/dist/apps/data/routes/exchanges.d.ts.map +1 -1
- package/dist/apps/data/routes/exchanges.js +114 -118
- package/dist/apps/data/routes/exchanges.js.map +1 -1
- package/dist/apps/data/routes/fee-amm.d.ts +24 -24
- package/dist/apps/data/routes/indexer.d.ts +35 -34
- package/dist/apps/data/routes/indexer.d.ts.map +1 -1
- package/dist/apps/data/routes/indexer.js +3 -2
- package/dist/apps/data/routes/indexer.js.map +1 -1
- package/dist/apps/data/routes/receipts.d.ts.map +1 -1
- package/dist/apps/data/routes/receipts.js +68 -47
- package/dist/apps/data/routes/receipts.js.map +1 -1
- package/dist/apps/data/routes/rpc.d.ts +31 -31
- package/dist/apps/data/routes/rpc.d.ts.map +1 -1
- package/dist/apps/data/routes/rpc.js +5 -1
- package/dist/apps/data/routes/rpc.js.map +1 -1
- package/dist/apps/data/routes/tokenlist.d.ts +12 -12
- package/dist/apps/data/routes/tokens.d.ts +461 -459
- package/dist/apps/data/routes/tokens.d.ts.map +1 -1
- package/dist/apps/data/routes/transactions.d.ts +313 -305
- package/dist/apps/data/routes/transactions.d.ts.map +1 -1
- package/dist/apps/data/routes/transactions.js +25 -23
- package/dist/apps/data/routes/transactions.js.map +1 -1
- package/dist/apps/data/routes/transfers.d.ts +108 -86
- package/dist/apps/data/routes/transfers.d.ts.map +1 -1
- package/dist/apps/data/routes/transfers.js +618 -105
- package/dist/apps/data/routes/transfers.js.map +1 -1
- package/dist/apps/data/routes/valuation.d.ts +21 -21
- package/dist/apps/data/routes/valuation.d.ts.map +1 -1
- package/dist/apps/data/routes/valuation.js +2 -2
- package/dist/apps/data/routes/valuation.js.map +1 -1
- package/dist/apps/data/routes/webhooks.d.ts +519 -868
- package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
- package/dist/apps/data/routes/webhooks.js +642 -353
- package/dist/apps/data/routes/webhooks.js.map +1 -1
- package/dist/apps/data/routes/zones.d.ts +31 -31
- package/dist/apps/data/routes/zones.d.ts.map +1 -1
- package/dist/apps/data/routes/zones.js +4 -2
- package/dist/apps/data/routes/zones.js.map +1 -1
- package/dist/apps/funding/routes/deposit-addresses.js +1 -1
- package/dist/apps/funding/routes/deposit-addresses.js.map +1 -1
- package/dist/apps/funding/routes/providers.d.ts +13 -13
- package/dist/apps/funding/routes/quotes.d.ts +89 -89
- package/dist/apps/funding/routes/transfers.d.ts +36 -36
- package/dist/apps/funding/routes/transfers.js +1 -1
- package/dist/apps/funding/routes/transfers.js.map +1 -1
- package/dist/apps/management/App.d.ts +11 -309
- package/dist/apps/management/App.d.ts.map +1 -1
- package/dist/apps/management/routes/api-keys.d.ts +74 -74
- package/dist/apps/management/routes/billing.js +1 -1
- package/dist/apps/management/routes/billing.js.map +1 -1
- package/dist/apps/management/routes/faucet.d.ts +35 -35
- package/dist/apps/management/routes/faucet.js +2 -2
- package/dist/apps/management/routes/faucet.js.map +1 -1
- package/dist/apps/management/routes/invitations.d.ts +134 -134
- package/dist/apps/management/routes/invite-links.d.ts +80 -80
- package/dist/apps/management/routes/members.d.ts +69 -69
- package/dist/apps/management/routes/orgs.d.ts +98 -98
- package/dist/apps/management/routes/projects.d.ts +60 -60
- package/dist/apps/management/routes/usage.d.ts +72 -72
- package/dist/apps/management/routes/verified-token-requests.d.ts +83 -83
- package/dist/apps/management/routes/webhooks.d.ts +203 -618
- package/dist/apps/management/routes/webhooks.d.ts.map +1 -1
- package/dist/apps/mcp.d.ts.map +1 -1
- package/dist/apps/mcp.js +4 -1
- package/dist/apps/mcp.js.map +1 -1
- package/dist/apps/mpp/App.d.ts +56 -44
- package/dist/apps/mpp/App.d.ts.map +1 -1
- package/dist/apps/mpp/Mpp.d.ts +3 -0
- package/dist/apps/mpp/Mpp.d.ts.map +1 -1
- package/dist/apps/mpp/Mpp.js +3 -0
- package/dist/apps/mpp/Mpp.js.map +1 -1
- package/dist/apps/mpp/internal/Adapter.d.ts.map +1 -1
- package/dist/apps/mpp/internal/Adapter.js +9 -2
- package/dist/apps/mpp/internal/Adapter.js.map +1 -1
- package/dist/db/Db.d.ts +4 -2
- package/dist/db/Db.d.ts.map +1 -1
- package/dist/db/Db.js +75 -15
- package/dist/db/Db.js.map +1 -1
- package/dist/db/Schema.d.ts +19 -0
- package/dist/db/Schema.d.ts.map +1 -1
- package/dist/db/Schema.js +24 -0
- package/dist/db/Schema.js.map +1 -1
- package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
- package/dist/db/tables/sponsoredTransactions.js +4 -1
- package/dist/db/tables/sponsoredTransactions.js.map +1 -1
- package/dist/db/tables/webhookQueueEvents.d.ts +122 -0
- package/dist/db/tables/webhookQueueEvents.d.ts.map +1 -0
- package/dist/db/tables/webhookQueueEvents.js +160 -0
- package/dist/db/tables/webhookQueueEvents.js.map +1 -0
- package/dist/db/tables/webhookSubscriptions.d.ts +58 -3
- package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -1
- package/dist/db/tables/webhookSubscriptions.js +86 -3
- package/dist/db/tables/webhookSubscriptions.js.map +1 -1
- package/dist/internal/Auth.d.ts +3 -0
- package/dist/internal/Auth.d.ts.map +1 -1
- package/dist/internal/Auth.js +6 -4
- package/dist/internal/Auth.js.map +1 -1
- package/dist/internal/MetricSink.d.ts +139 -3
- package/dist/internal/MetricSink.d.ts.map +1 -1
- package/dist/internal/MetricSink.js +144 -4
- package/dist/internal/MetricSink.js.map +1 -1
- package/dist/internal/RateLimit.d.ts +4 -6
- package/dist/internal/RateLimit.d.ts.map +1 -1
- package/dist/internal/RateLimit.js +15 -14
- package/dist/internal/RateLimit.js.map +1 -1
- package/dist/internal/Schema.js +1 -1
- package/dist/internal/Schema.js.map +1 -1
- package/dist/internal/Store.d.ts +2 -1
- package/dist/internal/Store.d.ts.map +1 -1
- package/dist/internal/Store.js +57 -18
- package/dist/internal/Store.js.map +1 -1
- package/dist/internal/Tidx.d.ts +1 -3
- package/dist/internal/Tidx.d.ts.map +1 -1
- package/dist/internal/Tidx.js +1 -3
- package/dist/internal/Tidx.js.map +1 -1
- package/dist/internal/Value.d.ts +2 -0
- package/dist/internal/Value.d.ts.map +1 -1
- package/dist/internal/Value.js +25 -0
- package/dist/internal/Value.js.map +1 -1
- package/dist/internal/Viem.d.ts.map +1 -1
- package/dist/internal/Viem.js +2 -1
- package/dist/internal/Viem.js.map +1 -1
- package/dist/internal/WebhookDestination.d.ts +2 -0
- package/dist/internal/WebhookDestination.d.ts.map +1 -1
- package/dist/internal/WebhookDestination.js +11 -0
- package/dist/internal/WebhookDestination.js.map +1 -1
- package/dist/internal/WebhookTransfer.d.ts +75 -0
- package/dist/internal/WebhookTransfer.d.ts.map +1 -0
- package/dist/internal/WebhookTransfer.js +299 -0
- package/dist/internal/WebhookTransfer.js.map +1 -0
- package/dist/internal/Webhooks.d.ts +176 -0
- package/dist/internal/Webhooks.d.ts.map +1 -1
- package/dist/internal/Webhooks.js +216 -20
- package/dist/internal/Webhooks.js.map +1 -1
- package/dist/internal/funding/Provider.d.ts +4 -0
- package/dist/internal/funding/Provider.d.ts.map +1 -1
- package/dist/internal/funding/Provider.js +11 -7
- package/dist/internal/funding/Provider.js.map +1 -1
- package/dist/internal/funding/Reconciliation.d.ts.map +1 -1
- package/dist/internal/funding/Reconciliation.js +128 -0
- package/dist/internal/funding/Reconciliation.js.map +1 -1
- package/dist/internal/funding/providers/relay.d.ts +6 -4
- package/dist/internal/funding/providers/relay.d.ts.map +1 -1
- package/dist/internal/funding/providers/relay.js +71 -9
- package/dist/internal/funding/providers/relay.js.map +1 -1
- package/package.json +4 -4
- package/src/ApiKeys.test.ts +20 -0
- package/src/ApiKeys.ts +28 -1
- package/src/App.test.ts +10 -8
- package/src/App.ts +1 -0
- package/src/Client.test-d.ts +61 -5
- package/src/Webhooks.bench.ts +714 -0
- package/src/Webhooks.memory.bench.ts +250 -0
- package/src/Webhooks.test.ts +77 -6
- package/src/Webhooks.ts +85 -29
- package/src/admin/apps/api-keys.test.ts +9 -0
- package/src/admin/apps/api-keys.ts +15 -0
- package/src/admin/ui/src/lib/query-client.ts +3 -2
- package/src/admin/ui/src/routes/api-keys.tsx +31 -9
- package/src/admin/ui.generated.ts +69 -69
- package/src/analytics/Analytics.test.ts +8 -0
- package/src/analytics/Analytics.ts +26 -0
- package/src/apps/Zones.test.ts +23 -5
- package/src/apps/Zones.ts +31 -10
- package/src/apps/composition.test.ts +1 -0
- package/src/apps/data/App.test.ts +2 -2
- package/src/apps/data/FxOracle.test.ts +18 -0
- package/src/apps/data/FxOracle.ts +28 -5
- package/src/apps/data/routes/activities.bench.ts +41 -0
- package/src/apps/data/routes/activities.test.ts +122 -2
- package/src/apps/data/routes/activities.ts +435 -32
- package/src/apps/data/routes/balances.bench.ts +21 -0
- package/src/apps/data/routes/balances.test.ts +119 -10
- package/src/apps/data/routes/balances.ts +164 -97
- package/src/apps/data/routes/blocks.bench.ts +34 -0
- package/src/apps/data/routes/blocks.test.ts +12 -6
- package/src/apps/data/routes/coingecko.bench.ts +53 -0
- package/src/apps/data/routes/earn.bench.ts +71 -0
- package/src/apps/data/routes/earn.test.ts +197 -0
- package/src/apps/data/routes/earn.ts +487 -45
- package/src/apps/data/routes/exchanges.bench.ts +85 -0
- package/src/apps/data/routes/exchanges.test.ts +7 -6
- package/src/apps/data/routes/exchanges.ts +124 -117
- package/src/apps/data/routes/fee-amm.bench.ts +13 -0
- package/src/apps/data/routes/indexer.bench.ts +8 -0
- package/src/apps/data/routes/indexer.test.ts +2 -1
- package/src/apps/data/routes/indexer.ts +3 -2
- package/src/apps/data/routes/receipts.bench.ts +39 -0
- package/src/apps/data/routes/receipts.test.ts +24 -7
- package/src/apps/data/routes/receipts.ts +67 -50
- package/src/apps/data/routes/rpc.bench.ts +14 -0
- package/src/apps/data/routes/rpc.test.ts +27 -0
- package/src/apps/data/routes/rpc.ts +5 -1
- package/src/apps/data/routes/tokenlist.bench.ts +3 -0
- package/src/apps/data/routes/tokens.bench.ts +76 -0
- package/src/apps/data/routes/tokens.test.ts +51 -0
- package/src/apps/data/routes/transactions.bench.ts +40 -0
- package/src/apps/data/routes/transactions.test.ts +42 -0
- package/src/apps/data/routes/transactions.ts +44 -39
- package/src/apps/data/routes/transfers.bench.ts +28 -0
- package/src/apps/data/routes/transfers.test.ts +402 -24
- package/src/apps/data/routes/transfers.ts +708 -101
- package/src/apps/data/routes/valuation.bench.ts +14 -0
- package/src/apps/data/routes/valuation.test.ts +26 -0
- package/src/apps/data/routes/valuation.ts +6 -2
- package/src/apps/data/routes/verified-tokens.bench.ts +8 -0
- package/src/apps/data/routes/webhooks.bench.ts +26 -0
- package/src/apps/data/routes/webhooks.scan.test.ts +326 -0
- package/src/apps/data/routes/webhooks.test.ts +466 -135
- package/src/apps/data/routes/webhooks.ts +820 -429
- package/src/apps/data/routes/zones.bench.ts +32 -0
- package/src/apps/data/routes/zones.test.ts +1 -1
- package/src/apps/data/routes/zones.ts +7 -2
- package/src/apps/funding/routes/chains.test.ts +1 -1
- package/src/apps/funding/routes/deposit-addresses.test.ts +1 -1
- package/src/apps/funding/routes/deposit-addresses.ts +1 -1
- package/src/apps/funding/routes/providers.test.ts +1 -1
- package/src/apps/funding/routes/quotes.test.ts +2 -2
- package/src/apps/funding/routes/transfers.test.ts +3 -6
- package/src/apps/funding/routes/transfers.ts +1 -1
- package/src/apps/management/routes/billing.ts +1 -1
- package/src/apps/management/routes/faucet.ts +2 -2
- package/src/apps/management/routes/me.test.ts +31 -0
- package/src/apps/mcp.ts +4 -1
- package/src/apps/mpp/Mpp.test.ts +13 -1
- package/src/apps/mpp/Mpp.ts +8 -0
- package/src/apps/mpp/internal/Adapter.ts +10 -2
- package/src/db/Db.test.ts +40 -6
- package/src/db/Db.ts +114 -19
- package/src/db/Schema.ts +32 -0
- package/src/db/tables/sponsoredTransactions.ts +9 -2
- package/src/db/tables/webhookQueueEvents.ts +285 -0
- package/src/db/tables/webhookSubscriptions.ts +131 -4
- package/src/internal/Auth.test.ts +61 -50
- package/src/internal/Auth.ts +9 -4
- package/src/internal/EarnVaults.test.ts +2 -2
- package/src/internal/MetricSink.test.ts +575 -0
- package/src/internal/MetricSink.ts +325 -10
- package/src/internal/RateLimit.test.ts +23 -18
- package/src/internal/RateLimit.ts +23 -15
- package/src/internal/Schema.ts +1 -1
- package/src/internal/Store.test.ts +99 -13
- package/src/internal/Store.ts +52 -17
- package/src/internal/Tidx.test.ts +4 -4
- package/src/internal/Tidx.ts +1 -3
- package/src/internal/Value.test.ts +25 -0
- package/src/internal/Value.ts +29 -0
- package/src/internal/Viem.test.ts +8 -0
- package/src/internal/Viem.ts +2 -1
- package/src/internal/WebhookDestination.test.ts +20 -0
- package/src/internal/WebhookDestination.ts +10 -0
- package/src/internal/WebhookTransfer.test.ts +89 -0
- package/src/internal/WebhookTransfer.ts +430 -0
- package/src/internal/Webhooks.test.ts +616 -0
- package/src/internal/Webhooks.ts +412 -24
- package/src/internal/funding/Provider.test.ts +48 -8
- package/src/internal/funding/Provider.ts +12 -7
- package/src/internal/funding/Reconciliation.local.test.ts +3 -1
- package/src/internal/funding/Reconciliation.test.ts +1240 -0
- package/src/internal/funding/Reconciliation.ts +136 -0
- package/src/internal/funding/SourceObservation.test.ts +1 -0
- package/src/internal/funding/providers/index.test.ts +73 -3
- package/src/internal/funding/providers/relay.test.ts +2 -9
- package/src/internal/funding/providers/relay.ts +72 -13
|
@@ -26,13 +26,31 @@ import * as Valuation from './valuation.js'
|
|
|
26
26
|
// the indexer SQL.
|
|
27
27
|
export const signature = 'event Transfer(address indexed from, address indexed to, uint256 value)'
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Half-open `[start, end)` bounds of the deterministic TIP-20 contract range.
|
|
31
|
+
* Full-width so the values compare correctly as PostgreSQL bytea, ClickHouse
|
|
32
|
+
* hex strings, and lowercased JavaScript strings.
|
|
33
|
+
*/
|
|
34
|
+
export const tip20AddressRange = [
|
|
32
35
|
'0x20c0000000000000000000000000000000000000',
|
|
33
36
|
'0x20c1000000000000000000000000000000000000',
|
|
34
37
|
] as const
|
|
35
38
|
|
|
39
|
+
// The global feed spans every TIP-20 token, so its newest-first query cannot
|
|
40
|
+
// follow `token_transfers`' token-first sort key. Block windows bound the sort.
|
|
41
|
+
const transferWindowBlocks = 100_000
|
|
42
|
+
|
|
43
|
+
// Sparse global scans retain progress without monopolizing one request.
|
|
44
|
+
const transferWindowScanCap = 8
|
|
45
|
+
|
|
46
|
+
// Recipient-first decoded scans usually fill a page immediately. A larger raw
|
|
47
|
+
// batch leaves room to discard non-TIP-20 `Transfer` events without extra I/O.
|
|
48
|
+
const decodedTransferBatchSize = 256
|
|
49
|
+
|
|
50
|
+
// Bound decoded recipient traversal while preserving the largest positional
|
|
51
|
+
// page and its extra row for `nextCursor` detection.
|
|
52
|
+
const decodedTransferRowCap = Schema.maxPageWindow + 1
|
|
53
|
+
|
|
36
54
|
// A memo'd TIP-20 transfer emits `TransferWithMemo` alongside the plain
|
|
37
55
|
// `Transfer`; the two rows match on `(tx_hash, from, to, amount, token)`. A
|
|
38
56
|
// text memo is decoded into `memo`; an MPP attribution fingerprint memo is
|
|
@@ -42,6 +60,10 @@ const memoSignature =
|
|
|
42
60
|
|
|
43
61
|
const tokenFields = ['token.logoUri', 'token.verified'] as const
|
|
44
62
|
|
|
63
|
+
// Concurrent metadata reads collapse into one deployless multicall. Small
|
|
64
|
+
// chunks keep each call under the Tempo RPC's per-call size limit.
|
|
65
|
+
const tokenReadChunkSize = 10
|
|
66
|
+
|
|
45
67
|
/** Zod schemas owned by the transfer handlers. */
|
|
46
68
|
export namespace schema {
|
|
47
69
|
/** A transfer token reference with RPC metadata. Inlined to avoid the `transfers`/`tokens` module cycle. */
|
|
@@ -363,9 +385,9 @@ export function transfers(options: transfers.Options = {}) {
|
|
|
363
385
|
|
|
364
386
|
const options = c.req.valid('query')
|
|
365
387
|
const chainId = options.chainId ?? c.get('chainId')
|
|
366
|
-
const denomination = options['valuation.currency']
|
|
388
|
+
const denomination = options['valuation.currency']
|
|
367
389
|
|
|
368
|
-
// Cursor pages carry current valuations and curated verification data.
|
|
390
|
+
// Cursor pages can carry current valuations and curated verification data.
|
|
369
391
|
if (options.cursor !== undefined) Cache.setPolicy(c, Cache.policies.metadata)
|
|
370
392
|
|
|
371
393
|
// Filters shared by the page query and the opt-in total count, so the
|
|
@@ -465,14 +487,13 @@ export function transfers(options: transfers.Options = {}) {
|
|
|
465
487
|
return suppress.has(key) ? [] : [transfer]
|
|
466
488
|
})
|
|
467
489
|
}
|
|
468
|
-
|
|
469
|
-
|
|
490
|
+
const includeLogoUri = options.include.includes('token.logoUri')
|
|
491
|
+
const includeVerified = options.include.includes('token.verified')
|
|
492
|
+
// Curated data is needed only for requested valuation or token fields.
|
|
470
493
|
const snapshot =
|
|
471
|
-
data.length > 0
|
|
494
|
+
data.length > 0 && (denomination !== undefined || includeLogoUri || includeVerified)
|
|
472
495
|
? await VerifiedTokens.snapshot(c, chainId).catch(() => undefined)
|
|
473
496
|
: undefined
|
|
474
|
-
const includeLogoUri = options.include.includes('token.logoUri')
|
|
475
|
-
const includeVerified = options.include.includes('token.verified')
|
|
476
497
|
const uniqueTokens = Array.from(
|
|
477
498
|
new Set(
|
|
478
499
|
data.flatMap((transfer) => [
|
|
@@ -481,10 +502,15 @@ export function transfers(options: transfers.Options = {}) {
|
|
|
481
502
|
]),
|
|
482
503
|
),
|
|
483
504
|
)
|
|
484
|
-
const tokensByAddress = new Map
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
505
|
+
const tokensByAddress = new Map<
|
|
506
|
+
string,
|
|
507
|
+
Awaited<ReturnType<typeof Tokens.getTokenMetadata>>
|
|
508
|
+
>()
|
|
509
|
+
await Timing.time(c, 'tokens', async () => {
|
|
510
|
+
for (let index = 0; index < uniqueTokens.length; index += tokenReadChunkSize) {
|
|
511
|
+
const group = uniqueTokens.slice(index, index + tokenReadChunkSize)
|
|
512
|
+
const entries = await Promise.all(
|
|
513
|
+
group.map(async (token) => {
|
|
488
514
|
const [logoUri, metadata] = await Promise.all([
|
|
489
515
|
includeLogoUri
|
|
490
516
|
? Timing.time(c, 'token_logo', () =>
|
|
@@ -507,25 +533,28 @@ export function transfers(options: transfers.Options = {}) {
|
|
|
507
533
|
},
|
|
508
534
|
] as const
|
|
509
535
|
}),
|
|
510
|
-
)
|
|
511
|
-
|
|
512
|
-
|
|
536
|
+
)
|
|
537
|
+
for (const entry of entries) tokensByAddress.set(...entry)
|
|
538
|
+
}
|
|
539
|
+
})
|
|
513
540
|
|
|
514
541
|
// Value each leg via its curated display currency, in the requested
|
|
515
542
|
// denomination.
|
|
516
|
-
const rates =
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
543
|
+
const rates = denomination
|
|
544
|
+
? await Valuation.ratesFor(c, {
|
|
545
|
+
currencies: data.flatMap((transfer) =>
|
|
546
|
+
[
|
|
547
|
+
transfer.sourceToken.address,
|
|
548
|
+
...(transfer.destinationToken ? [transfer.destinationToken.address] : []),
|
|
549
|
+
].flatMap((address) => {
|
|
550
|
+
const held = snapshot?.byAddress.get(address.toLowerCase())?.currency
|
|
551
|
+
return held === undefined ? [] : [held]
|
|
552
|
+
}),
|
|
553
|
+
),
|
|
554
|
+
denomination,
|
|
555
|
+
oracle,
|
|
556
|
+
})
|
|
557
|
+
: undefined
|
|
529
558
|
const enrich = (leg: CrossToken) => {
|
|
530
559
|
const metadata = tokensByAddress.get(leg.address.toLowerCase())
|
|
531
560
|
if (!metadata) throw new Error(`Token metadata unavailable for ${leg.address}`)
|
|
@@ -536,12 +565,16 @@ export function transfers(options: transfers.Options = {}) {
|
|
|
536
565
|
currency: metadata.currency,
|
|
537
566
|
decimals: metadata.decimals,
|
|
538
567
|
}),
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
568
|
+
...(denomination
|
|
569
|
+
? {
|
|
570
|
+
valuation: Valuation.valuationFor({
|
|
571
|
+
amount: BigInt(leg.amount),
|
|
572
|
+
denomination,
|
|
573
|
+
rates,
|
|
574
|
+
token: snapshot?.byAddress.get(leg.address.toLowerCase()),
|
|
575
|
+
}),
|
|
576
|
+
}
|
|
577
|
+
: {}),
|
|
545
578
|
},
|
|
546
579
|
token: {
|
|
547
580
|
address: leg.address,
|
|
@@ -632,7 +665,7 @@ export function query(c: Context<App.Environment>, options: query.Options) {
|
|
|
632
665
|
const { chainId, timing, ...rest } = options
|
|
633
666
|
const store = c.get('store')
|
|
634
667
|
const tidx = c.get('getTidx')(chainId)
|
|
635
|
-
const cursor =
|
|
668
|
+
const cursor = decodeTransferCursor(options.cursor)
|
|
636
669
|
|
|
637
670
|
return Timing.time(c, timing, () =>
|
|
638
671
|
Store.memoize(
|
|
@@ -644,7 +677,7 @@ export function query(c: Context<App.Environment>, options: query.Options) {
|
|
|
644
677
|
}
|
|
645
678
|
},
|
|
646
679
|
{
|
|
647
|
-
key: `transfers:v2:${chainId}:${options.order}:${options.token ?? ''}:${options.eitherSide ?? ''}:${options.sender ?? ''}:${options.recipient ?? ''}:${options.fromBlock ?? ''}:${options.toBlock ?? ''}:${options.fromTimestamp ?? ''}:${options.toTimestamp ?? ''}:${cursor ? `cursor:${cursor[0]}:${cursor[1]}` : options.page !== undefined && options.page > 1 ? `page:${options.page}` : 'head'}:${options.limit}`,
|
|
680
|
+
key: `transfers:v2:${chainId}:${options.order}:${options.token ?? ''}:${options.eitherSide ?? ''}:${options.sender ?? ''}:${options.recipient ?? ''}:${options.fromBlock ?? ''}:${options.toBlock ?? ''}:${options.fromTimestamp ?? ''}:${options.toTimestamp ?? ''}:${cursor ? `cursor:${cursor.position[0]}:${cursor.position[1]}:${cursor.offset}` : options.page !== undefined && options.page > 1 ? `page:${options.page}` : 'head'}:${options.limit}`,
|
|
648
681
|
store,
|
|
649
682
|
ttl: Ttl.seconds(15),
|
|
650
683
|
},
|
|
@@ -694,7 +727,7 @@ export declare namespace query {
|
|
|
694
727
|
}
|
|
695
728
|
|
|
696
729
|
/**
|
|
697
|
-
* Context-free core of {@link query}: reads token-scoped rows from
|
|
730
|
+
* Context-free core of {@link query}: reads token-scoped and global rows from
|
|
698
731
|
* `token_transfers`, otherwise the decoded `Transfer` CTE, then paginates and
|
|
699
732
|
* decodes them. Also used by the webhook poller.
|
|
700
733
|
*/
|
|
@@ -706,52 +739,216 @@ export async function scan(deps: scan.Deps, options: scan.Options): Promise<scan
|
|
|
706
739
|
// Keyset pagination: anchor the page below the previous row's `(block, log_idx)`
|
|
707
740
|
// position instead of a numeric offset, so rows arriving at the head can't
|
|
708
741
|
// shift items across pages. A malformed cursor falls back to the head page.
|
|
709
|
-
const cursor =
|
|
742
|
+
const cursor = decodeTransferCursor(options.cursor)
|
|
710
743
|
// Bounded positional lane (exclusive with `cursor` at the schema): page 1 is
|
|
711
744
|
// the head page, so it shares the head's SQL and cache entries; deeper pages
|
|
712
745
|
// translate to a SQL OFFSET of `(page - 1) * limit` rows.
|
|
713
|
-
const
|
|
746
|
+
const pageOffset =
|
|
714
747
|
options.page !== undefined && options.page > 1 ? (options.page - 1) * limit : undefined
|
|
748
|
+
const offset = pageOffset ?? (cursor?.offset === 0 ? undefined : cursor?.offset)
|
|
715
749
|
const address = options.eitherSide
|
|
750
|
+
const addressScoped = address !== undefined
|
|
751
|
+
const recipient = options.recipient
|
|
752
|
+
const recipientDecoded = !tokenScoped && recipient !== undefined
|
|
753
|
+
const decoded = !tokenScoped && (addressScoped || recipientDecoded)
|
|
754
|
+
const splitAddress = addressScoped && !recipientDecoded
|
|
755
|
+
// Only one-sided `"from"`/`"to"` equality shapes hit the planner's
|
|
756
|
+
// deterministic top-N kill, so only they take the capped fallback.
|
|
757
|
+
const sideFiltered =
|
|
758
|
+
addressScoped || options.sender !== undefined || options.recipient !== undefined
|
|
759
|
+
const global = !tokenScoped && !sideFiltered
|
|
716
760
|
|
|
717
761
|
// Transfer events filtered by any combination of contract / sender /
|
|
718
762
|
// recipient / block / timestamp. The address shortcut is split below so
|
|
719
763
|
// each upstream query can use one indexed side.
|
|
720
|
-
const filters =
|
|
721
|
-
|
|
764
|
+
const filters = global
|
|
765
|
+
? [
|
|
766
|
+
`token >= '${tip20AddressRange[0]}'`,
|
|
767
|
+
`token < '${tip20AddressRange[1]}'`,
|
|
768
|
+
...(options.fromBlock !== undefined ? [`block_num >= ${options.fromBlock}`] : []),
|
|
769
|
+
...(options.toBlock !== undefined ? [`block_num <= ${options.toBlock}`] : []),
|
|
770
|
+
...(options.fromTimestamp !== undefined
|
|
771
|
+
? [`block_timestamp >= '${formatClickHouseDateTime(options.fromTimestamp)}'`]
|
|
772
|
+
: []),
|
|
773
|
+
...(options.toTimestamp !== undefined
|
|
774
|
+
? [`block_timestamp <= '${formatClickHouseDateTime(options.toTimestamp)}'`]
|
|
775
|
+
: []),
|
|
776
|
+
]
|
|
777
|
+
: decoded
|
|
778
|
+
? [
|
|
779
|
+
...(options.sender !== undefined ? [`"from" = '${options.sender}'`] : []),
|
|
780
|
+
...(recipient !== undefined ? [`"to" = '${recipient}'`] : []),
|
|
781
|
+
...(options.fromBlock !== undefined ? [`block_num >= ${options.fromBlock}`] : []),
|
|
782
|
+
...(options.toBlock !== undefined ? [`block_num <= ${options.toBlock}`] : []),
|
|
783
|
+
...(options.fromTimestamp !== undefined
|
|
784
|
+
? [`block_timestamp >= '${options.fromTimestamp}'`]
|
|
785
|
+
: []),
|
|
786
|
+
...(options.toTimestamp !== undefined
|
|
787
|
+
? [`block_timestamp <= '${options.toTimestamp}'`]
|
|
788
|
+
: []),
|
|
789
|
+
]
|
|
790
|
+
: filterClauses(options)
|
|
791
|
+
if (cursor !== undefined && !decoded)
|
|
722
792
|
filters.push(
|
|
723
793
|
Cursor.keyset([
|
|
724
|
-
{ literal: Cursor.literal(cursor[0]
|
|
725
|
-
{ literal: Cursor.literal(cursor[1]
|
|
794
|
+
{ literal: Cursor.literal(cursor.position[0], 'int'), name: 'block_num', order },
|
|
795
|
+
{ literal: Cursor.literal(cursor.position[1], 'int'), name: 'log_idx', order },
|
|
726
796
|
]),
|
|
727
797
|
)
|
|
728
|
-
const addressScoped = address !== undefined
|
|
729
798
|
const window = addressScoped ? (offset ?? 0) + limit + 1 : limit + 1
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
const sideFiltered =
|
|
733
|
-
addressScoped || options.sender !== undefined || options.recipient !== undefined
|
|
734
|
-
const fetchRows = async (side?: 'from' | 'to') => {
|
|
799
|
+
const fetchRows = async (options_fetch: fetchRows.Options = {}) => {
|
|
800
|
+
const side = options_fetch.side
|
|
735
801
|
const sideFilters =
|
|
736
802
|
side !== undefined && address !== undefined
|
|
737
803
|
? [`"${side}" = '${address}'`, ...filters]
|
|
738
804
|
: filters
|
|
739
|
-
const
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
805
|
+
const queryFilters = [
|
|
806
|
+
...sideFilters,
|
|
807
|
+
...(options_fetch.lo !== undefined ? [`block_num >= ${options_fetch.lo}`] : []),
|
|
808
|
+
...(options_fetch.hi !== undefined ? [`block_num <= ${options_fetch.hi}`] : []),
|
|
809
|
+
]
|
|
810
|
+
const materialized = tokenScoped || global
|
|
811
|
+
// Keep a distinct alias until tidx.ts parses ClickHouse timestamps as UTC.
|
|
812
|
+
const select = materialized
|
|
813
|
+
? `"from", "to", token AS address, amount AS value, tx_hash, block_num, log_idx, toString(block_timestamp, 'UTC') AS block_timestamp_utc`
|
|
814
|
+
: `"from", "to", address, value, tx_hash, block_num, log_idx, block_timestamp`
|
|
815
|
+
const table = materialized ? 'token_transfers' : 'Transfer'
|
|
816
|
+
const execute = async (
|
|
817
|
+
filters_execute: readonly string[],
|
|
818
|
+
queryLimit: number,
|
|
819
|
+
queryOffset?: number,
|
|
820
|
+
): Promise<readonly Record<string, unknown>[]> => {
|
|
821
|
+
const where = filters_execute.length > 0 ? `WHERE ${filters_execute.join(' AND ')}` : ''
|
|
743
822
|
const result = await tidx.fetch({
|
|
744
823
|
chainId,
|
|
745
|
-
...(
|
|
824
|
+
...(materialized ? { engine: 'clickhouse' } : { signatures: [signature] }),
|
|
746
825
|
query: `
|
|
747
826
|
SELECT ${select}
|
|
748
827
|
FROM ${table}
|
|
749
828
|
${where}
|
|
750
829
|
ORDER BY block_num ${direction}, log_idx ${direction}
|
|
751
|
-
LIMIT ${
|
|
830
|
+
LIMIT ${queryLimit}${queryOffset !== undefined ? ` OFFSET ${queryOffset}` : ''}
|
|
752
831
|
` as string,
|
|
753
832
|
})
|
|
754
|
-
return
|
|
833
|
+
return result.rows
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
if (decoded) {
|
|
837
|
+
const target = (offset ?? 0) + limit + 1
|
|
838
|
+
// Filter the physical token column so ClickHouse can use the materialized table ordering.
|
|
839
|
+
const filters_materialized = [
|
|
840
|
+
`token >= '${tip20AddressRange[0]}'`,
|
|
841
|
+
`token < '${tip20AddressRange[1]}'`,
|
|
842
|
+
...(options.sender !== undefined ? [`"from" = '${options.sender}'`] : []),
|
|
843
|
+
...(recipient !== undefined ? [`"to" = '${recipient}'`] : []),
|
|
844
|
+
...(options.fromBlock !== undefined ? [`block_num >= ${options.fromBlock}`] : []),
|
|
845
|
+
...(options.toBlock !== undefined ? [`block_num <= ${options.toBlock}`] : []),
|
|
846
|
+
...(options.fromTimestamp !== undefined
|
|
847
|
+
? [`block_timestamp >= '${formatClickHouseDateTime(options.fromTimestamp)}'`]
|
|
848
|
+
: []),
|
|
849
|
+
...(options.toTimestamp !== undefined
|
|
850
|
+
? [`block_timestamp <= '${formatClickHouseDateTime(options.toTimestamp)}'`]
|
|
851
|
+
: []),
|
|
852
|
+
]
|
|
853
|
+
if (cursor !== undefined)
|
|
854
|
+
filters_materialized.push(
|
|
855
|
+
Cursor.keyset([
|
|
856
|
+
{ literal: Cursor.literal(cursor.position[0], 'int'), name: 'block_num', order },
|
|
857
|
+
{ literal: Cursor.literal(cursor.position[1], 'int'), name: 'log_idx', order },
|
|
858
|
+
]),
|
|
859
|
+
)
|
|
860
|
+
const sideFilters_materialized =
|
|
861
|
+
side !== undefined && address !== undefined
|
|
862
|
+
? [`"${side}" = '${address}'`, ...filters_materialized]
|
|
863
|
+
: filters_materialized
|
|
864
|
+
const where_materialized =
|
|
865
|
+
sideFilters_materialized.length > 0 ? `WHERE ${sideFilters_materialized.join(' AND ')}` : ''
|
|
866
|
+
try {
|
|
867
|
+
const result = await tidx.fetch({
|
|
868
|
+
chainId,
|
|
869
|
+
engine: 'clickhouse',
|
|
870
|
+
query: `
|
|
871
|
+
SELECT DISTINCT "from", "to", token AS address, amount AS value, tx_hash, block_num, log_idx, toString(block_timestamp, 'UTC') AS block_timestamp_utc
|
|
872
|
+
FROM token_transfers
|
|
873
|
+
${where_materialized}
|
|
874
|
+
ORDER BY block_num ${direction}, log_idx ${direction}
|
|
875
|
+
LIMIT ${target}
|
|
876
|
+
` as string,
|
|
877
|
+
})
|
|
878
|
+
return {
|
|
879
|
+
capped: false,
|
|
880
|
+
rows: result.rows.slice(splitAddress ? 0 : (offset ?? 0), target),
|
|
881
|
+
sample: false,
|
|
882
|
+
}
|
|
883
|
+
} catch (error) {
|
|
884
|
+
// Older chains can reject materialized address scans, so retain the
|
|
885
|
+
// bounded decoded scan as a compatibility fallback.
|
|
886
|
+
if (!Tidx.isQueryRejection(error)) throw error
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const rows: Record<string, unknown>[] = []
|
|
890
|
+
let anchor = cursor?.position
|
|
891
|
+
let rawRowCount = 0
|
|
892
|
+
while (rows.length < target && rawRowCount < decodedTransferRowCap) {
|
|
893
|
+
const batchLimit = Math.min(
|
|
894
|
+
Math.max(target - rows.length, decodedTransferBatchSize),
|
|
895
|
+
Schema.maxPageWindow,
|
|
896
|
+
decodedTransferRowCap - rawRowCount,
|
|
897
|
+
)
|
|
898
|
+
const batch = await (async () => {
|
|
899
|
+
if (anchor === undefined) return execute(queryFilters, batchLimit)
|
|
900
|
+
|
|
901
|
+
// Signature-decoded queries reject or mis-plan tuple keysets. Read
|
|
902
|
+
// the cursor block and strict outer block range separately.
|
|
903
|
+
const block = Cursor.literal(anchor[0]!, 'int')
|
|
904
|
+
const index = Cursor.literal(anchor[1]!, 'int')
|
|
905
|
+
const comparison = order === 'asc' ? '>' : '<'
|
|
906
|
+
const sameBlock = await execute(
|
|
907
|
+
[...queryFilters, `block_num = ${block}`, `log_idx ${comparison} ${index}`],
|
|
908
|
+
batchLimit,
|
|
909
|
+
)
|
|
910
|
+
if (sameBlock.length >= batchLimit) return sameBlock
|
|
911
|
+
const outerBlocks = await execute(
|
|
912
|
+
[...queryFilters, `block_num ${comparison} ${block}`],
|
|
913
|
+
batchLimit - sameBlock.length,
|
|
914
|
+
)
|
|
915
|
+
return [...sameBlock, ...outerBlocks]
|
|
916
|
+
})()
|
|
917
|
+
|
|
918
|
+
rawRowCount += batch.length
|
|
919
|
+
rows.push(...batch.filter(isTip20TransferRow))
|
|
920
|
+
if (batch.length < batchLimit) break
|
|
921
|
+
const last = batch.at(-1)
|
|
922
|
+
const block = Value.toNumber(last?.['block_num'])
|
|
923
|
+
const index = Value.toNumber(last?.['log_idx'])
|
|
924
|
+
if (block === undefined || index === undefined) break
|
|
925
|
+
anchor = [block, index]
|
|
926
|
+
}
|
|
927
|
+
const capped = rows.length < target && rawRowCount >= decodedTransferRowCap
|
|
928
|
+
return {
|
|
929
|
+
capped,
|
|
930
|
+
...(capped && anchor !== undefined
|
|
931
|
+
? {
|
|
932
|
+
resume: {
|
|
933
|
+
offset: Math.max(0, (offset ?? 0) - rows.length),
|
|
934
|
+
position: anchor,
|
|
935
|
+
},
|
|
936
|
+
}
|
|
937
|
+
: {}),
|
|
938
|
+
rows: rows.slice(splitAddress ? 0 : (offset ?? 0), target),
|
|
939
|
+
sample: false,
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
const queryLimit = options_fetch.limit ?? window
|
|
944
|
+
const queryOffset = options_fetch.offset ?? (!addressScoped ? offset : undefined)
|
|
945
|
+
const where = queryFilters.length > 0 ? `WHERE ${queryFilters.join(' AND ')}` : ''
|
|
946
|
+
try {
|
|
947
|
+
return {
|
|
948
|
+
capped: false,
|
|
949
|
+
rows: await execute(queryFilters, queryLimit, queryOffset),
|
|
950
|
+
sample: false,
|
|
951
|
+
}
|
|
755
952
|
} catch (error) {
|
|
756
953
|
// The planner kills a one-sided top-N walk when its matches sit far
|
|
757
954
|
// from head (422 `db error`). Retry as a plain capped scan, returning
|
|
@@ -759,7 +956,7 @@ export async function scan(deps: scan.Deps, options: scan.Options): Promise<scan
|
|
|
759
956
|
if (!sideFiltered || !Tidx.isDeterministicError(error)) throw error
|
|
760
957
|
const result = await tidx.fetch({
|
|
761
958
|
chainId,
|
|
762
|
-
...(
|
|
959
|
+
...(materialized ? { engine: 'clickhouse' } : { signatures: [signature] }),
|
|
763
960
|
query: `
|
|
764
961
|
SELECT ${select}
|
|
765
962
|
FROM ${table}
|
|
@@ -767,16 +964,93 @@ export async function scan(deps: scan.Deps, options: scan.Options): Promise<scan
|
|
|
767
964
|
LIMIT ${Schema.countCap}
|
|
768
965
|
` as string,
|
|
769
966
|
})
|
|
770
|
-
return { rows: result.rows, sample: true }
|
|
967
|
+
return { capped: result.rows.length >= Schema.countCap, rows: result.rows, sample: true }
|
|
771
968
|
}
|
|
772
969
|
}
|
|
773
970
|
|
|
971
|
+
const windowed = global
|
|
972
|
+
const fetchWindowedRows = async () => {
|
|
973
|
+
const indexedCeiling =
|
|
974
|
+
options.toTimestamp === undefined
|
|
975
|
+
? await indexedHead(tidx, { chainId })
|
|
976
|
+
: await indexedBlockAtOrBefore(tidx, { chainId, timestamp: options.toTimestamp })
|
|
977
|
+
if (indexedCeiling === undefined) return { capped: false, rows: [], sample: false }
|
|
978
|
+
|
|
979
|
+
const floor = options.fromBlock ?? 0
|
|
980
|
+
const ceiling = Math.min(options.toBlock ?? indexedCeiling, indexedCeiling)
|
|
981
|
+
const target = (offset ?? 0) + limit + 1
|
|
982
|
+
const rows: Record<string, unknown>[] = []
|
|
983
|
+
const cursorBlock = cursor?.position[0]
|
|
984
|
+
let resumePosition: readonly [number, number] | undefined
|
|
985
|
+
let scannedWindows = 0
|
|
986
|
+
let anchor =
|
|
987
|
+
order === 'asc'
|
|
988
|
+
? Math.max(floor, cursorBlock ?? floor)
|
|
989
|
+
: Math.min(ceiling, cursorBlock ?? ceiling)
|
|
990
|
+
while (
|
|
991
|
+
anchor >= floor &&
|
|
992
|
+
anchor <= ceiling &&
|
|
993
|
+
rows.length < target &&
|
|
994
|
+
scannedWindows < transferWindowScanCap
|
|
995
|
+
) {
|
|
996
|
+
const window_lo =
|
|
997
|
+
order === 'asc' ? anchor : Math.max(floor, anchor - transferWindowBlocks + 1)
|
|
998
|
+
const window_hi =
|
|
999
|
+
order === 'desc' ? anchor : Math.min(ceiling, anchor + transferWindowBlocks - 1)
|
|
1000
|
+
let windowOffset = 0
|
|
1001
|
+
while (rows.length < target) {
|
|
1002
|
+
const queryLimit = Math.min(target - rows.length, Schema.maxPageWindow)
|
|
1003
|
+
const result = await fetchRows({
|
|
1004
|
+
hi: window_hi,
|
|
1005
|
+
limit: queryLimit,
|
|
1006
|
+
lo: window_lo,
|
|
1007
|
+
offset: windowOffset,
|
|
1008
|
+
})
|
|
1009
|
+
rows.push(...result.rows)
|
|
1010
|
+
windowOffset += result.rows.length
|
|
1011
|
+
if (result.rows.length < queryLimit) break
|
|
1012
|
+
}
|
|
1013
|
+
scannedWindows++
|
|
1014
|
+
// Resume after the fully scanned boundary without skipping the next block.
|
|
1015
|
+
resumePosition = order === 'asc' ? [window_hi, Number.MAX_SAFE_INTEGER] : [window_lo, 0]
|
|
1016
|
+
anchor = order === 'asc' ? window_hi + 1 : window_lo - 1
|
|
1017
|
+
}
|
|
1018
|
+
const capped =
|
|
1019
|
+
rows.length < target &&
|
|
1020
|
+
anchor >= floor &&
|
|
1021
|
+
anchor <= ceiling &&
|
|
1022
|
+
scannedWindows >= transferWindowScanCap
|
|
1023
|
+
return {
|
|
1024
|
+
capped,
|
|
1025
|
+
...(capped && resumePosition !== undefined
|
|
1026
|
+
? {
|
|
1027
|
+
resume: {
|
|
1028
|
+
offset: Math.max(0, (offset ?? 0) - rows.length),
|
|
1029
|
+
position: resumePosition,
|
|
1030
|
+
},
|
|
1031
|
+
}
|
|
1032
|
+
: {}),
|
|
1033
|
+
rows: rows.slice(offset ?? 0),
|
|
1034
|
+
sample: false,
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// With `to = recipient`, the either-side address is redundant when equal
|
|
1039
|
+
// and otherwise reduces to `from = address`, so one ordered stream suffices.
|
|
1040
|
+
const recipientAddressSide =
|
|
1041
|
+
recipientDecoded && address !== undefined && address !== recipient ? 'from' : undefined
|
|
774
1042
|
// TIDX cannot plan `(from = X OR to = X)` over the decoded Transfer table.
|
|
775
|
-
// Fetch one
|
|
776
|
-
const fetched =
|
|
777
|
-
? await
|
|
778
|
-
:
|
|
779
|
-
|
|
1043
|
+
// Fetch one stream per side only when windowing or recipient reduction does not apply.
|
|
1044
|
+
const fetched = windowed
|
|
1045
|
+
? [await fetchWindowedRows()]
|
|
1046
|
+
: splitAddress
|
|
1047
|
+
? await Promise.all([fetchRows({ side: 'from' }), fetchRows({ side: 'to' })])
|
|
1048
|
+
: [await fetchRows(recipientAddressSide === undefined ? {} : { side: recipientAddressSide })]
|
|
1049
|
+
const { capped, resume, rows } = collate(fetched, {
|
|
1050
|
+
limit: limit + 1,
|
|
1051
|
+
offset: offset ?? 0,
|
|
1052
|
+
order,
|
|
1053
|
+
})
|
|
780
1054
|
|
|
781
1055
|
// The next page anchors below the last fetched row's `(block, log_idx)`.
|
|
782
1056
|
const page = Cursor.paginate({
|
|
@@ -798,7 +1072,7 @@ export async function scan(deps: scan.Deps, options: scan.Options): Promise<scan
|
|
|
798
1072
|
const amount = Value.toIntegerString(row['value'])
|
|
799
1073
|
const blockNumber = Value.toNumber(row['block_num'])
|
|
800
1074
|
const logIndex = Value.toNumber(row['log_idx'])
|
|
801
|
-
const timestamp = Value.toIsoDateTime(row['block_timestamp'])
|
|
1075
|
+
const timestamp = Value.toIsoDateTime(row['block_timestamp_utc'] ?? row['block_timestamp'])
|
|
802
1076
|
if (
|
|
803
1077
|
tokenAddress.success &&
|
|
804
1078
|
from.success &&
|
|
@@ -824,29 +1098,141 @@ export async function scan(deps: scan.Deps, options: scan.Options): Promise<scan
|
|
|
824
1098
|
})
|
|
825
1099
|
}
|
|
826
1100
|
|
|
827
|
-
//
|
|
828
|
-
//
|
|
1101
|
+
// Every matching row through `resume.position` was inspected, so advancing
|
|
1102
|
+
// there cannot skip a match. Positional continuations retain their offset.
|
|
1103
|
+
const nextCursor = resume
|
|
1104
|
+
? Cursor.encode(resume.offset > 0 ? [...resume.position, resume.offset] : [...resume.position])
|
|
1105
|
+
: capped
|
|
1106
|
+
? null
|
|
1107
|
+
: page.nextCursor
|
|
829
1108
|
return {
|
|
830
1109
|
capped,
|
|
831
|
-
hasMore: capped ? false : page.hasMore,
|
|
1110
|
+
hasMore: resume ? true : capped ? false : page.hasMore,
|
|
832
1111
|
items,
|
|
833
1112
|
limit,
|
|
834
|
-
nextCursor
|
|
1113
|
+
nextCursor,
|
|
1114
|
+
...(resume ? { resumeCursor: resume.position } : {}),
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
declare namespace fetchRows {
|
|
1119
|
+
/** Source-side, block-window, and pagination constraints for one upstream scan. */
|
|
1120
|
+
type Options = {
|
|
1121
|
+
/** Inclusive highest block number. */
|
|
1122
|
+
hi?: number | undefined
|
|
1123
|
+
/** Maximum rows to return. */
|
|
1124
|
+
limit?: number | undefined
|
|
1125
|
+
/** Inclusive lowest block number. */
|
|
1126
|
+
lo?: number | undefined
|
|
1127
|
+
/** Rows to skip before returning results. */
|
|
1128
|
+
offset?: number | undefined
|
|
1129
|
+
/** Indexed event participant side. */
|
|
1130
|
+
side?: 'from' | 'to' | undefined
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/** Reads the latest block available to TIDX for a descending window scan. */
|
|
1135
|
+
async function indexedHead(tidx: Tidx.Client, options: indexedHead.Options) {
|
|
1136
|
+
const result = await tidx.fetch({
|
|
1137
|
+
chainId: options.chainId,
|
|
1138
|
+
query: 'SELECT num FROM blocks ORDER BY num DESC LIMIT 1',
|
|
1139
|
+
})
|
|
1140
|
+
return Value.toNumber(result.rows[0]?.['num'])
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
declare namespace indexedHead {
|
|
1144
|
+
/** Chain whose indexed head should be read. */
|
|
1145
|
+
type Options = {
|
|
1146
|
+
/** Target chain id. */
|
|
1147
|
+
chainId: number
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/** Reads the latest indexed block at or before a timestamp. */
|
|
1152
|
+
async function indexedBlockAtOrBefore(tidx: Tidx.Client, options: indexedBlockAtOrBefore.Options) {
|
|
1153
|
+
const { chainId, timestamp } = options
|
|
1154
|
+
const result = await tidx.fetch({
|
|
1155
|
+
chainId,
|
|
1156
|
+
query: `
|
|
1157
|
+
SELECT num
|
|
1158
|
+
FROM blocks
|
|
1159
|
+
WHERE timestamp <= '${Tidx.escape(timestamp)}'
|
|
1160
|
+
ORDER BY num DESC
|
|
1161
|
+
LIMIT 1
|
|
1162
|
+
` as string,
|
|
1163
|
+
})
|
|
1164
|
+
return Value.toNumber(result.rows[0]?.['num'])
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
declare namespace indexedBlockAtOrBefore {
|
|
1168
|
+
/** Chain and cutoff for the highest qualifying indexed block. */
|
|
1169
|
+
type Options = {
|
|
1170
|
+
/** Target chain id. */
|
|
1171
|
+
chainId: number
|
|
1172
|
+
/** Inclusive timestamp cutoff. */
|
|
1173
|
+
timestamp: string
|
|
835
1174
|
}
|
|
836
1175
|
}
|
|
837
1176
|
|
|
1177
|
+
/** Formats an ISO timestamp for ClickHouse `DateTime64` comparisons. */
|
|
1178
|
+
function formatClickHouseDateTime(value: string) {
|
|
1179
|
+
return value.replace('T', ' ').replace(/Z$/, '')
|
|
1180
|
+
}
|
|
1181
|
+
|
|
838
1182
|
/**
|
|
839
1183
|
* Collates per-side transfer streams into one ordered page window: split and
|
|
840
1184
|
* sampled streams are deduped, globally ordered, and paged app-side, while a
|
|
841
|
-
* lone engine-paged stream passes through.
|
|
842
|
-
*
|
|
843
|
-
* unfit to anchor pagination or exact counts.
|
|
1185
|
+
* lone engine-paged stream passes through. Ordered capped streams may carry a
|
|
1186
|
+
* safe raw continuation; unordered samples cannot.
|
|
844
1187
|
*/
|
|
845
1188
|
export function collate(
|
|
846
1189
|
fetched: readonly collate.Stream[],
|
|
847
1190
|
options: collate.Options,
|
|
848
1191
|
): collate.Result {
|
|
849
|
-
const capped = fetched.some((
|
|
1192
|
+
const capped = fetched.some((stream) => stream.capped)
|
|
1193
|
+
const cappedStreams = fetched.filter((stream) => stream.capped)
|
|
1194
|
+
const resumes = cappedStreams.flatMap((stream) => (stream.resume ? [stream.resume] : []))
|
|
1195
|
+
if (
|
|
1196
|
+
fetched.length > 1 &&
|
|
1197
|
+
capped &&
|
|
1198
|
+
!fetched.some((stream) => stream.sample) &&
|
|
1199
|
+
resumes.length === cappedStreams.length
|
|
1200
|
+
) {
|
|
1201
|
+
// Advance only through the least-progressed side, so the next strict
|
|
1202
|
+
// keyset scan cannot rediscover any returned rows.
|
|
1203
|
+
const position = resumes
|
|
1204
|
+
.map((resume) => resume.position)
|
|
1205
|
+
.reduce((candidate, current) => {
|
|
1206
|
+
const comparison = candidate[0] - current[0] || candidate[1] - current[1]
|
|
1207
|
+
if (options.order === 'asc') return comparison <= 0 ? candidate : current
|
|
1208
|
+
return comparison >= 0 ? candidate : current
|
|
1209
|
+
})
|
|
1210
|
+
const safeRows = fetched.flatMap((stream) =>
|
|
1211
|
+
stream.rows.filter((row) => {
|
|
1212
|
+
const blockNumber = Value.toNumber(row['block_num'])
|
|
1213
|
+
const logIndex = Value.toNumber(row['log_idx'])
|
|
1214
|
+
if (blockNumber === undefined || logIndex === undefined) return false
|
|
1215
|
+
const comparison = blockNumber - position[0] || logIndex - position[1]
|
|
1216
|
+
return options.order === 'asc' ? comparison <= 0 : comparison >= 0
|
|
1217
|
+
}),
|
|
1218
|
+
)
|
|
1219
|
+
const merged = mergeTransferRows(safeRows, {
|
|
1220
|
+
limit: options.offset + options.limit,
|
|
1221
|
+
offset: 0,
|
|
1222
|
+
order: options.order,
|
|
1223
|
+
})
|
|
1224
|
+
const rows = merged.slice(options.offset, options.offset + options.limit)
|
|
1225
|
+
if (rows.length >= options.limit) return { capped: false, rows }
|
|
1226
|
+
return {
|
|
1227
|
+
capped: true,
|
|
1228
|
+
resume: {
|
|
1229
|
+
offset: Math.max(0, options.offset - merged.length),
|
|
1230
|
+
position,
|
|
1231
|
+
},
|
|
1232
|
+
rows,
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
const resume = fetched.length === 1 ? fetched[0]?.resume : undefined
|
|
850
1236
|
const rows =
|
|
851
1237
|
fetched.length > 1 || fetched.some(({ sample }) => sample)
|
|
852
1238
|
? mergeTransferRows(
|
|
@@ -854,17 +1240,28 @@ export function collate(
|
|
|
854
1240
|
options,
|
|
855
1241
|
)
|
|
856
1242
|
: (fetched[0]?.rows ?? [])
|
|
857
|
-
return { capped, rows }
|
|
1243
|
+
return { capped, ...(resume ? { resume } : {}), rows }
|
|
858
1244
|
}
|
|
859
1245
|
|
|
860
1246
|
export declare namespace collate {
|
|
861
1247
|
/** One fetched transfer stream. */
|
|
862
1248
|
type Stream = {
|
|
1249
|
+
/** Whether upstream bounds truncated this stream. */
|
|
1250
|
+
capped: boolean
|
|
863
1251
|
/** Raw TIDX rows. */
|
|
864
1252
|
rows: readonly Record<string, unknown>[]
|
|
1253
|
+
/** Ordered raw position and unmatched positional offset after a bounded scan. */
|
|
1254
|
+
resume?: Resume | undefined
|
|
865
1255
|
/** Whether the rows are an unordered, unpaged capped fallback sample. */
|
|
866
1256
|
sample: boolean
|
|
867
1257
|
}
|
|
1258
|
+
/** Progress retained after a bounded ordered scan. */
|
|
1259
|
+
type Resume = {
|
|
1260
|
+
/** Filtered rows that a positional continuation still needs to skip. */
|
|
1261
|
+
offset: number
|
|
1262
|
+
/** Last ordered position safely inspected by the bounded scan. */
|
|
1263
|
+
position: readonly [blockNumber: number, logIndex: number]
|
|
1264
|
+
}
|
|
868
1265
|
/** The page window applied to the merged stream. */
|
|
869
1266
|
type Options = {
|
|
870
1267
|
/** Maximum merged rows to return. */
|
|
@@ -876,10 +1273,12 @@ export declare namespace collate {
|
|
|
876
1273
|
}
|
|
877
1274
|
/** The collated page window. */
|
|
878
1275
|
type Result = {
|
|
879
|
-
/** Whether
|
|
1276
|
+
/** Whether an upstream work bound truncated any stream. */
|
|
880
1277
|
capped: boolean
|
|
881
1278
|
/** The ordered page window rows. */
|
|
882
1279
|
rows: readonly Record<string, unknown>[]
|
|
1280
|
+
/** Ordered continuation state, absent for unordered capped samples. */
|
|
1281
|
+
resume?: Resume | undefined
|
|
883
1282
|
}
|
|
884
1283
|
}
|
|
885
1284
|
|
|
@@ -935,12 +1334,33 @@ export declare namespace scan {
|
|
|
935
1334
|
}
|
|
936
1335
|
/** A page of scanned `Transfer` rows. */
|
|
937
1336
|
type Page = {
|
|
938
|
-
/** Whether
|
|
1337
|
+
/** Whether an upstream work bound truncated the page. */
|
|
939
1338
|
capped: boolean
|
|
940
1339
|
hasMore: boolean
|
|
941
1340
|
items: readonly Item[]
|
|
942
1341
|
limit: number
|
|
943
1342
|
nextCursor: string | null
|
|
1343
|
+
/** Last ordered position safely inspected, when a capped scan can resume. */
|
|
1344
|
+
resumeCursor?: readonly [blockNumber: number, logIndex: number] | undefined
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
/** Decodes ordinary transfer cursors and capped positional continuations. */
|
|
1349
|
+
function decodeTransferCursor(token: string | undefined): decodeTransferCursor.Result | undefined {
|
|
1350
|
+
if (token === undefined) return undefined
|
|
1351
|
+
const position = Cursor.decode(token, ['int', 'int'])
|
|
1352
|
+
if (position) return { offset: 0, position: position as [blockNumber: number, logIndex: number] }
|
|
1353
|
+
const continuation = Cursor.decode(token, ['int', 'int', 'int'])
|
|
1354
|
+
if (!continuation) return undefined
|
|
1355
|
+
const [blockNumber, logIndex, offset] = continuation as [number, number, number]
|
|
1356
|
+
return { offset: Math.max(0, offset), position: [blockNumber, logIndex] }
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
declare namespace decodeTransferCursor {
|
|
1360
|
+
/** Decoded raw position plus any filtered rows still to skip. */
|
|
1361
|
+
type Result = {
|
|
1362
|
+
offset: number
|
|
1363
|
+
position: readonly [blockNumber: number, logIndex: number]
|
|
944
1364
|
}
|
|
945
1365
|
}
|
|
946
1366
|
|
|
@@ -949,7 +1369,7 @@ export declare namespace scan {
|
|
|
949
1369
|
* unscoped queries constrain the decoded `Transfer` emitter to TIP-20 contracts.
|
|
950
1370
|
*/
|
|
951
1371
|
function filterClauses(options: filterClauses.Options): string[] {
|
|
952
|
-
const { fromBlock, fromTimestamp, recipient, sender, toBlock,
|
|
1372
|
+
const { fromBlock, fromTimestamp, recipient, sender, toBlock, token, toTimestamp } = options
|
|
953
1373
|
const filters =
|
|
954
1374
|
token === undefined
|
|
955
1375
|
? [`address >= '${tip20AddressRange[0]}'`, `address < '${tip20AddressRange[1]}'`]
|
|
@@ -971,6 +1391,13 @@ declare namespace filterClauses {
|
|
|
971
1391
|
>
|
|
972
1392
|
}
|
|
973
1393
|
|
|
1394
|
+
function isTip20TransferRow(row: Record<string, unknown>) {
|
|
1395
|
+
const address = row['address']
|
|
1396
|
+
if (typeof address !== 'string') return false
|
|
1397
|
+
const normalized = address.toLowerCase()
|
|
1398
|
+
return normalized >= tip20AddressRange[0] && normalized < tip20AddressRange[1]
|
|
1399
|
+
}
|
|
1400
|
+
|
|
974
1401
|
/**
|
|
975
1402
|
* Capped total-row count for the `Transfer` feed, sharing {@link query}'s
|
|
976
1403
|
* filters (minus pagination) so the count matches the page it annotates. Timed
|
|
@@ -1033,16 +1460,187 @@ export async function countScan(
|
|
|
1033
1460
|
// rows, classify each transaction, and subtract the folded-away legs. Bounded
|
|
1034
1461
|
// by `countCap` like the raw count, so it stays within the indexer's budget.
|
|
1035
1462
|
if (options.crossToken) {
|
|
1463
|
+
const global =
|
|
1464
|
+
!tokenScoped &&
|
|
1465
|
+
address === undefined &&
|
|
1466
|
+
options.sender === undefined &&
|
|
1467
|
+
options.recipient === undefined
|
|
1468
|
+
if (global) {
|
|
1469
|
+
// A capped count promises a conservative lower bound, so aggregate the
|
|
1470
|
+
// deduplicated capped sample without the global block sort or 10,000-row response.
|
|
1471
|
+
const materializedFilters = [
|
|
1472
|
+
`token >= '${tip20AddressRange[0]}'`,
|
|
1473
|
+
`token < '${tip20AddressRange[1]}'`,
|
|
1474
|
+
...(options.fromBlock !== undefined ? [`block_num >= ${options.fromBlock}`] : []),
|
|
1475
|
+
...(options.toBlock !== undefined ? [`block_num <= ${options.toBlock}`] : []),
|
|
1476
|
+
...(options.fromTimestamp !== undefined
|
|
1477
|
+
? [`block_timestamp >= '${options.fromTimestamp}'`]
|
|
1478
|
+
: []),
|
|
1479
|
+
...(options.toTimestamp !== undefined
|
|
1480
|
+
? [`block_timestamp <= '${options.toTimestamp}'`]
|
|
1481
|
+
: []),
|
|
1482
|
+
]
|
|
1483
|
+
const result = await tidx.fetch({
|
|
1484
|
+
chainId,
|
|
1485
|
+
engine: 'clickhouse',
|
|
1486
|
+
query: `
|
|
1487
|
+
SELECT
|
|
1488
|
+
count(*) AS total,
|
|
1489
|
+
countIf(
|
|
1490
|
+
"to" = '${Addresses.stablecoinDex}'
|
|
1491
|
+
OR "from" = '${Addresses.stablecoinDex}'
|
|
1492
|
+
OR "to" = '${Addresses.feeManager}'
|
|
1493
|
+
) AS suppressed
|
|
1494
|
+
FROM (
|
|
1495
|
+
SELECT tx_hash, log_idx, "from", "to"
|
|
1496
|
+
FROM token_transfers FINAL
|
|
1497
|
+
WHERE ${materializedFilters.join(' AND ')}
|
|
1498
|
+
LIMIT ${Schema.countCap}
|
|
1499
|
+
) AS capped
|
|
1500
|
+
` as string,
|
|
1501
|
+
})
|
|
1502
|
+
const total = Value.toNumber(result.rows[0]?.['total']) ?? 0
|
|
1503
|
+
const suppressed = Value.toNumber(result.rows[0]?.['suppressed']) ?? 0
|
|
1504
|
+
if (total >= Schema.countCap) {
|
|
1505
|
+
return {
|
|
1506
|
+
totalCount: Math.max(0, total - suppressed),
|
|
1507
|
+
totalCountCapped: true,
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
if (suppressed === 0) return { totalCount: total, totalCountCapped: false }
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
if (address !== undefined) {
|
|
1514
|
+
const materializedFilters = [
|
|
1515
|
+
...(options.token === undefined
|
|
1516
|
+
? [`token >= '${tip20AddressRange[0]}'`, `token < '${tip20AddressRange[1]}'`]
|
|
1517
|
+
: [`token = '${options.token}'`]),
|
|
1518
|
+
...(options.sender !== undefined ? [`"from" = '${options.sender}'`] : []),
|
|
1519
|
+
...(options.recipient !== undefined ? [`"to" = '${options.recipient}'`] : []),
|
|
1520
|
+
...(options.fromBlock !== undefined ? [`block_num >= ${options.fromBlock}`] : []),
|
|
1521
|
+
...(options.toBlock !== undefined ? [`block_num <= ${options.toBlock}`] : []),
|
|
1522
|
+
...(options.fromTimestamp !== undefined
|
|
1523
|
+
? [`block_timestamp >= '${options.fromTimestamp}'`]
|
|
1524
|
+
: []),
|
|
1525
|
+
...(options.toTimestamp !== undefined
|
|
1526
|
+
? [`block_timestamp <= '${options.toTimestamp}'`]
|
|
1527
|
+
: []),
|
|
1528
|
+
]
|
|
1529
|
+
const sentFilters = [`"from" = '${address}'`, ...materializedFilters]
|
|
1530
|
+
const receivedFilters = [
|
|
1531
|
+
`"to" = '${address}'`,
|
|
1532
|
+
`"from" != '${address}'`,
|
|
1533
|
+
...materializedFilters,
|
|
1534
|
+
]
|
|
1535
|
+
const aggregate = await tidx.fetch({
|
|
1536
|
+
chainId,
|
|
1537
|
+
engine: 'clickhouse',
|
|
1538
|
+
query: `
|
|
1539
|
+
SELECT
|
|
1540
|
+
'sent' AS side,
|
|
1541
|
+
count(*) AS total,
|
|
1542
|
+
countIf(
|
|
1543
|
+
"to" = '${Addresses.stablecoinDex}'
|
|
1544
|
+
OR "from" = '${Addresses.stablecoinDex}'
|
|
1545
|
+
OR "to" = '${Addresses.feeManager}'
|
|
1546
|
+
) AS suppressed
|
|
1547
|
+
FROM (
|
|
1548
|
+
SELECT "from", "to"
|
|
1549
|
+
FROM token_transfers
|
|
1550
|
+
WHERE ${sentFilters.join(' AND ')}
|
|
1551
|
+
LIMIT ${Schema.countCap}
|
|
1552
|
+
) AS sent
|
|
1553
|
+
UNION ALL
|
|
1554
|
+
SELECT
|
|
1555
|
+
'received' AS side,
|
|
1556
|
+
count(*) AS total,
|
|
1557
|
+
countIf(
|
|
1558
|
+
"to" = '${Addresses.stablecoinDex}'
|
|
1559
|
+
OR "from" = '${Addresses.stablecoinDex}'
|
|
1560
|
+
OR "to" = '${Addresses.feeManager}'
|
|
1561
|
+
) AS suppressed
|
|
1562
|
+
FROM (
|
|
1563
|
+
SELECT "from", "to"
|
|
1564
|
+
FROM token_transfers
|
|
1565
|
+
WHERE ${receivedFilters.join(' AND ')}
|
|
1566
|
+
LIMIT ${Schema.countCap}
|
|
1567
|
+
) AS received
|
|
1568
|
+
` as string,
|
|
1569
|
+
})
|
|
1570
|
+
const sent = aggregate.rows.find((row) => Value.toText(row['side']) === 'sent')
|
|
1571
|
+
const received = aggregate.rows.find((row) => Value.toText(row['side']) === 'received')
|
|
1572
|
+
const sentTotal = Value.toNumber(sent?.['total']) ?? 0
|
|
1573
|
+
const receivedTotal = Value.toNumber(received?.['total']) ?? 0
|
|
1574
|
+
const total = sentTotal + receivedTotal
|
|
1575
|
+
const suppressed =
|
|
1576
|
+
(Value.toNumber(sent?.['suppressed']) ?? 0) +
|
|
1577
|
+
(Value.toNumber(received?.['suppressed']) ?? 0)
|
|
1578
|
+
const capped =
|
|
1579
|
+
sentTotal >= Schema.countCap || receivedTotal >= Schema.countCap || total >= Schema.countCap
|
|
1580
|
+
if (capped)
|
|
1581
|
+
return {
|
|
1582
|
+
totalCount: Math.min(Math.max(0, total - suppressed), Schema.countCap),
|
|
1583
|
+
totalCountCapped: true,
|
|
1584
|
+
}
|
|
1585
|
+
if (suppressed === 0) return { totalCount: total, totalCountCapped: false }
|
|
1586
|
+
|
|
1587
|
+
// A below-cap aggregate proves these unsorted scans are complete, so the
|
|
1588
|
+
// exact bundle classifier does not need a global sort.
|
|
1589
|
+
const select = 'tx_hash, block_num, log_idx, "from", "to", token AS address, amount AS value'
|
|
1590
|
+
const fetchRows = async (sideFilters: readonly string[]) => {
|
|
1591
|
+
const result = await tidx.fetch({
|
|
1592
|
+
chainId,
|
|
1593
|
+
engine: 'clickhouse',
|
|
1594
|
+
query: `
|
|
1595
|
+
SELECT ${select}
|
|
1596
|
+
FROM token_transfers
|
|
1597
|
+
WHERE ${sideFilters.join(' AND ')}
|
|
1598
|
+
LIMIT ${Schema.countCap}
|
|
1599
|
+
` as string,
|
|
1600
|
+
})
|
|
1601
|
+
return result.rows
|
|
1602
|
+
}
|
|
1603
|
+
const [sentRows, receivedRows] = await Promise.all([
|
|
1604
|
+
fetchRows(sentFilters),
|
|
1605
|
+
fetchRows(receivedFilters),
|
|
1606
|
+
])
|
|
1607
|
+
const rows = [...sentRows, ...receivedRows]
|
|
1608
|
+
const transactionHashes = [...legsByTransaction(rows)]
|
|
1609
|
+
.filter(([, legs]) => legs.some(isBundleLeg))
|
|
1610
|
+
.map(([transactionHash]) => transactionHash)
|
|
1611
|
+
const transactionHashSet = new Set(transactionHashes)
|
|
1612
|
+
const candidateRows = rows.filter((row) => {
|
|
1613
|
+
const transactionHash = Value.toText(row['tx_hash'])
|
|
1614
|
+
return (
|
|
1615
|
+
transactionHash !== undefined && transactionHashSet.has(transactionHash.toLowerCase())
|
|
1616
|
+
)
|
|
1617
|
+
})
|
|
1618
|
+
const blocks = candidateRows.flatMap((row) => {
|
|
1619
|
+
const block = Value.toNumber(row['block_num'])
|
|
1620
|
+
return block === undefined ? [] : [block]
|
|
1621
|
+
})
|
|
1622
|
+
if (blocks.length === 0 || transactionHashes.length === 0)
|
|
1623
|
+
return foldCount(rows, { capped: false })
|
|
1624
|
+
// Address predicates omit sibling bundle legs. Load each candidate
|
|
1625
|
+
// transaction completely, then suppress only address-matched rows.
|
|
1626
|
+
const classification = await tidx.fetch({
|
|
1627
|
+
chainId,
|
|
1628
|
+
engine: 'clickhouse',
|
|
1629
|
+
query: `
|
|
1630
|
+
SELECT tx_hash, block_num, log_idx, "from", "to", token AS address, amount AS value
|
|
1631
|
+
FROM token_transfers
|
|
1632
|
+
WHERE block_num BETWEEN ${Math.min(...blocks)} AND ${Math.max(...blocks)}
|
|
1633
|
+
AND tx_hash IN (${transactionHashes.map((hash) => `'${hash}'`).join(', ')})
|
|
1634
|
+
` as string,
|
|
1635
|
+
})
|
|
1636
|
+
return foldCount(rows, { capped: false, classificationRows: classification.rows })
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1036
1639
|
// Only one-sided `"from"`/`"to"` equality shapes hit the planner's
|
|
1037
1640
|
// deterministic top-N kill, so only they take the capped fallback.
|
|
1038
|
-
const sideFiltered =
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
const sideFilters =
|
|
1042
|
-
side !== undefined && address !== undefined
|
|
1043
|
-
? [`"${side}" = '${address}'`, ...filters]
|
|
1044
|
-
: filters
|
|
1045
|
-
const sideWhere = sideFilters.length > 0 ? `WHERE ${sideFilters.join(' AND ')}` : ''
|
|
1641
|
+
const sideFiltered = options.sender !== undefined || options.recipient !== undefined
|
|
1642
|
+
const fetchRows = async () => {
|
|
1643
|
+
const where = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''
|
|
1046
1644
|
const select = `tx_hash, block_num, log_idx, "from", "to", ${tokenScoped ? 'token AS address, amount AS value' : 'address, value'}`
|
|
1047
1645
|
const table = tokenScoped ? 'token_transfers' : 'Transfer'
|
|
1048
1646
|
try {
|
|
@@ -1052,12 +1650,12 @@ export async function countScan(
|
|
|
1052
1650
|
query: `
|
|
1053
1651
|
SELECT ${select}
|
|
1054
1652
|
FROM ${table}
|
|
1055
|
-
${
|
|
1653
|
+
${where}
|
|
1056
1654
|
ORDER BY block_num, log_idx
|
|
1057
1655
|
LIMIT ${Schema.countCap}
|
|
1058
1656
|
` as string,
|
|
1059
1657
|
})
|
|
1060
|
-
return { rows: result.rows, sample: false }
|
|
1658
|
+
return { capped: false, rows: result.rows, sample: false }
|
|
1061
1659
|
} catch (error) {
|
|
1062
1660
|
// Same planner kill as the page's side scan; the folded count is
|
|
1063
1661
|
// already capped, so a plain capped scan keeps its semantics.
|
|
@@ -1068,17 +1666,14 @@ export async function countScan(
|
|
|
1068
1666
|
query: `
|
|
1069
1667
|
SELECT ${select}
|
|
1070
1668
|
FROM ${table}
|
|
1071
|
-
${
|
|
1669
|
+
${where}
|
|
1072
1670
|
LIMIT ${Schema.countCap}
|
|
1073
1671
|
` as string,
|
|
1074
1672
|
})
|
|
1075
|
-
return { rows: result.rows, sample: true }
|
|
1673
|
+
return { capped: result.rows.length >= Schema.countCap, rows: result.rows, sample: true }
|
|
1076
1674
|
}
|
|
1077
1675
|
}
|
|
1078
|
-
const fetched =
|
|
1079
|
-
address !== undefined
|
|
1080
|
-
? await Promise.all([fetchRows('from'), fetchRows('to')])
|
|
1081
|
-
: [await fetchRows()]
|
|
1676
|
+
const fetched = [await fetchRows()]
|
|
1082
1677
|
const { capped, rows } = collate(fetched, { limit: Schema.countCap, offset: 0, order: 'asc' })
|
|
1083
1678
|
return foldCount(rows, { capped })
|
|
1084
1679
|
}
|
|
@@ -1139,15 +1734,24 @@ export function foldCount(
|
|
|
1139
1734
|
rows: readonly Record<string, unknown>[],
|
|
1140
1735
|
options: foldCount.Options,
|
|
1141
1736
|
): count.Result {
|
|
1142
|
-
const { capped } = options
|
|
1737
|
+
const { capped, classificationRows = rows } = options
|
|
1738
|
+
const rowKeys = new Set<string>()
|
|
1739
|
+
for (const row of rows) {
|
|
1740
|
+
const transactionHash = Value.toText(row['tx_hash'])
|
|
1741
|
+
const logIndex = Value.toNumber(row['log_idx'])
|
|
1742
|
+
if (transactionHash && logIndex !== undefined) rowKeys.add(eventKey(transactionHash, logIndex))
|
|
1743
|
+
}
|
|
1143
1744
|
let suppressed = 0
|
|
1144
|
-
for (const legs of legsByTransaction(rows)
|
|
1745
|
+
for (const [transactionHash, legs] of legsByTransaction(capped ? rows : classificationRows)) {
|
|
1145
1746
|
if (capped) {
|
|
1146
1747
|
suppressed += legs.filter(isBundleLeg).length
|
|
1147
1748
|
continue
|
|
1148
1749
|
}
|
|
1149
1750
|
const bundle = classifyCrossToken(legs)
|
|
1150
|
-
if (bundle)
|
|
1751
|
+
if (bundle)
|
|
1752
|
+
suppressed += bundle.suppressLogIndexes.filter((logIndex) =>
|
|
1753
|
+
rowKeys.has(eventKey(transactionHash, logIndex)),
|
|
1754
|
+
).length
|
|
1151
1755
|
}
|
|
1152
1756
|
return {
|
|
1153
1757
|
totalCount: rows.length - suppressed,
|
|
@@ -1160,6 +1764,8 @@ export declare namespace foldCount {
|
|
|
1160
1764
|
type Options = {
|
|
1161
1765
|
/** Whether the rows include a cap-truncated fallback sample. */
|
|
1162
1766
|
capped: boolean
|
|
1767
|
+
/** Complete transaction legs used to classify the filtered rows. */
|
|
1768
|
+
classificationRows?: readonly Record<string, unknown>[] | undefined
|
|
1163
1769
|
}
|
|
1164
1770
|
}
|
|
1165
1771
|
|
|
@@ -1289,12 +1895,13 @@ type CrossTokenBundle = {
|
|
|
1289
1895
|
* transfer and is left untouched.
|
|
1290
1896
|
*/
|
|
1291
1897
|
function classifyCrossToken(legs: readonly Leg[]): CrossTokenBundle | undefined {
|
|
1292
|
-
const
|
|
1293
|
-
const
|
|
1898
|
+
const ordered = [...legs].sort((a, b) => a.logIndex - b.logIndex)
|
|
1899
|
+
const toDex = ordered.find((leg) => Address.isEqual(leg.to, Addresses.stablecoinDex))
|
|
1900
|
+
const fromDex = ordered.find((leg) => Address.isEqual(leg.from, Addresses.stablecoinDex))
|
|
1294
1901
|
if (!toDex || !fromDex) return undefined
|
|
1295
1902
|
const swapper = fromDex.to
|
|
1296
1903
|
const destTokenAddress = fromDex.address
|
|
1297
|
-
const delivery =
|
|
1904
|
+
const delivery = ordered.find(
|
|
1298
1905
|
(leg) =>
|
|
1299
1906
|
Address.isEqual(leg.address, destTokenAddress) &&
|
|
1300
1907
|
Address.isEqual(leg.from, swapper) &&
|
|
@@ -1304,7 +1911,7 @@ function classifyCrossToken(legs: readonly Leg[]): CrossTokenBundle | undefined
|
|
|
1304
1911
|
)
|
|
1305
1912
|
// Require a token change so plain same-token DEX activity is not folded.
|
|
1306
1913
|
if (!delivery || Address.isEqual(toDex.address, delivery.address)) return undefined
|
|
1307
|
-
const suppressLogIndexes =
|
|
1914
|
+
const suppressLogIndexes = ordered
|
|
1308
1915
|
.filter((leg) => leg.logIndex !== delivery.logIndex && isBundleLeg(leg))
|
|
1309
1916
|
.map((leg) => leg.logIndex)
|
|
1310
1917
|
return {
|