tapimo 0.8.0 → 0.10.0
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/App.d.ts +7410 -7613
- package/dist/App.d.ts.map +1 -1
- package/dist/App.js +0 -1
- 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/apps/verified-tokens.d.ts +74 -74
- package/dist/apps/Zones.d.ts +34 -22
- package/dist/apps/Zones.d.ts.map +1 -1
- package/dist/apps/Zones.js +9 -8
- package/dist/apps/Zones.js.map +1 -1
- package/dist/apps/data/App.d.ts +3120 -2782
- 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.map +1 -1
- package/dist/apps/data/routes/activities.js +32 -27
- package/dist/apps/data/routes/activities.js.map +1 -1
- package/dist/apps/data/routes/addresses.d.ts +283 -52
- package/dist/apps/data/routes/addresses.d.ts.map +1 -1
- package/dist/apps/data/routes/balances.d.ts +291 -21
- package/dist/apps/data/routes/balances.d.ts.map +1 -1
- package/dist/apps/data/routes/balances.js +232 -66
- package/dist/apps/data/routes/balances.js.map +1 -1
- package/dist/apps/data/routes/blocks.d.ts +4 -4
- package/dist/apps/data/routes/coingecko.d.ts +54 -54
- package/dist/apps/data/routes/earn.d.ts +642 -126
- package/dist/apps/data/routes/earn.d.ts.map +1 -1
- package/dist/apps/data/routes/earn.js +627 -58
- package/dist/apps/data/routes/earn.js.map +1 -1
- package/dist/apps/data/routes/exchanges.d.ts +120 -120
- 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/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/tokens.d.ts +2 -0
- package/dist/apps/data/routes/tokens.d.ts.map +1 -1
- package/dist/apps/data/routes/transactions.d.ts +59 -51
- 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 +40 -18
- 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/verified-tokens.d.ts +37 -37
- package/dist/apps/data/routes/webhooks.d.ts +102 -505
- package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
- package/dist/apps/data/routes/webhooks.js +484 -342
- package/dist/apps/data/routes/webhooks.js.map +1 -1
- package/dist/apps/data/routes/zones.d.ts +12 -12
- package/dist/apps/data/routes/zones.d.ts.map +1 -1
- package/dist/apps/data/routes/zones.js +3 -1
- package/dist/apps/data/routes/zones.js.map +1 -1
- package/dist/apps/funding/App.d.ts +127 -386
- package/dist/apps/funding/App.d.ts.map +1 -1
- package/dist/apps/funding/App.js +20 -5
- package/dist/apps/funding/App.js.map +1 -1
- package/dist/apps/funding/routes/deposit-addresses.d.ts +141 -532
- package/dist/apps/funding/routes/deposit-addresses.d.ts.map +1 -1
- package/dist/apps/funding/routes/deposit-addresses.js +21 -103
- package/dist/apps/funding/routes/deposit-addresses.js.map +1 -1
- package/dist/apps/funding/routes/transfers.d.ts +4 -4
- package/dist/apps/funding/routes/transfers.js +5 -5
- package/dist/apps/funding/routes/transfers.js.map +1 -1
- package/dist/apps/management/App.d.ts +429 -727
- package/dist/apps/management/App.d.ts.map +1 -1
- package/dist/apps/management/routes/api-keys.d.ts +24 -24
- package/dist/apps/management/routes/billing.d.ts +72 -72
- package/dist/apps/management/routes/faucet.d.ts +6 -6
- package/dist/apps/management/routes/invite-links.d.ts +38 -38
- package/dist/apps/management/routes/me.d.ts +6 -6
- package/dist/apps/management/routes/members.d.ts +30 -30
- package/dist/apps/management/routes/projects.d.ts +20 -20
- package/dist/apps/management/routes/usage.d.ts +24 -24
- package/dist/apps/management/routes/webhooks.d.ts +158 -573
- package/dist/apps/management/routes/webhooks.d.ts.map +1 -1
- package/dist/apps/mpp/App.d.ts +12 -0
- 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 +2 -2
- package/dist/db/Db.d.ts.map +1 -1
- package/dist/db/Db.js +25 -15
- package/dist/db/Db.js.map +1 -1
- package/dist/db/tables/fundingDeposits.d.ts +18 -1
- package/dist/db/tables/fundingDeposits.d.ts.map +1 -1
- package/dist/db/tables/fundingDeposits.js +46 -2
- package/dist/db/tables/fundingDeposits.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/webhookSubscriptions.d.ts +56 -3
- package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -1
- package/dist/db/tables/webhookSubscriptions.js +76 -3
- package/dist/db/tables/webhookSubscriptions.js.map +1 -1
- package/dist/internal/Auth.d.ts +4 -0
- package/dist/internal/Auth.d.ts.map +1 -1
- package/dist/internal/Auth.js +11 -2
- package/dist/internal/Auth.js.map +1 -1
- package/dist/internal/MetricSink.d.ts +112 -3
- package/dist/internal/MetricSink.d.ts.map +1 -1
- package/dist/internal/MetricSink.js +116 -3
- package/dist/internal/MetricSink.js.map +1 -1
- package/dist/internal/Schema.js +1 -1
- package/dist/internal/Schema.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/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 +55 -0
- package/dist/internal/WebhookTransfer.d.ts.map +1 -0
- package/dist/internal/WebhookTransfer.js +296 -0
- package/dist/internal/WebhookTransfer.js.map +1 -0
- package/dist/internal/Webhooks.d.ts +37 -0
- package/dist/internal/Webhooks.d.ts.map +1 -1
- package/dist/internal/Webhooks.js +87 -13
- package/dist/internal/Webhooks.js.map +1 -1
- package/dist/internal/funding/Provider.d.ts +10 -6
- 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 +229 -16
- package/dist/internal/funding/Reconciliation.js.map +1 -1
- package/dist/internal/funding/providers/relay.d.ts +3 -0
- package/dist/internal/funding/providers/relay.d.ts.map +1 -1
- package/dist/internal/funding/providers/relay.js +18 -3
- package/dist/internal/funding/providers/relay.js.map +1 -1
- package/package.json +3 -3
- package/src/App.test.ts +32 -0
- package/src/App.ts +4 -1
- package/src/Client.test-d.ts +116 -38
- 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 +83 -29
- package/src/apps/Zones.test.ts +5 -4
- package/src/apps/Zones.ts +9 -8
- package/src/apps/composition.test.ts +39 -10
- package/src/apps/data/FxOracle.test.ts +18 -0
- package/src/apps/data/FxOracle.ts +28 -5
- package/src/apps/data/routes/activities.test.ts +19 -0
- package/src/apps/data/routes/activities.ts +34 -30
- package/src/apps/data/routes/balances.test.ts +161 -10
- package/src/apps/data/routes/balances.ts +339 -147
- package/src/apps/data/routes/blocks.test.ts +12 -6
- package/src/apps/data/routes/earn.test.ts +286 -9
- package/src/apps/data/routes/earn.ts +841 -58
- package/src/apps/data/routes/exchanges.test.ts +7 -6
- package/src/apps/data/routes/exchanges.ts +124 -117
- package/src/apps/data/routes/receipts.test.ts +24 -7
- package/src/apps/data/routes/receipts.ts +67 -50
- package/src/apps/data/routes/tokens.test.ts +51 -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.test.ts +402 -24
- package/src/apps/data/routes/transfers.ts +708 -101
- package/src/apps/data/routes/valuation.test.ts +26 -0
- package/src/apps/data/routes/valuation.ts +6 -2
- package/src/apps/data/routes/webhooks.test.ts +414 -130
- package/src/apps/data/routes/webhooks.ts +603 -419
- package/src/apps/data/routes/zones.ts +6 -1
- package/src/apps/funding/App.ts +20 -5
- package/src/apps/funding/routes/deposit-addresses.test.ts +43 -22
- package/src/apps/funding/routes/deposit-addresses.ts +34 -117
- package/src/apps/funding/routes/quotes.test.ts +1 -1
- package/src/apps/funding/routes/transfers.test.ts +6 -25
- package/src/apps/funding/routes/transfers.ts +5 -5
- 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 +42 -19
- package/src/db/tables/fundingDeposits.test.ts +101 -0
- package/src/db/tables/fundingDeposits.ts +71 -2
- package/src/db/tables/sponsoredTransactions.ts +9 -2
- package/src/db/tables/webhookSubscriptions.ts +116 -4
- package/src/internal/Auth.test.ts +28 -0
- package/src/internal/Auth.ts +15 -1
- package/src/internal/EarnVaults.test.ts +2 -2
- package/src/internal/MetricSink.test.ts +518 -0
- package/src/internal/MetricSink.ts +266 -9
- package/src/internal/Schema.ts +1 -1
- package/src/internal/Tidx.test.ts +4 -4
- package/src/internal/Tidx.ts +1 -3
- 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.ts +426 -0
- package/src/internal/Webhooks.test.ts +137 -0
- package/src/internal/Webhooks.ts +131 -17
- package/src/internal/funding/Provider.test.ts +47 -7
- package/src/internal/funding/Provider.ts +16 -11
- package/src/internal/funding/Reconciliation.local.test.ts +3 -1
- package/src/internal/funding/Reconciliation.test.ts +1532 -54
- package/src/internal/funding/Reconciliation.ts +296 -53
- package/src/internal/funding/SourceObservation.test.ts +3 -2
- package/src/internal/funding/providers/relay.test.ts +1 -4
- package/src/internal/funding/providers/relay.ts +23 -3
|
@@ -22,18 +22,35 @@ import * as Valuation from './valuation.js';
|
|
|
22
22
|
// signature exposes a decoded `Transfer` CTE (with `from`/`to`/`value`) to
|
|
23
23
|
// the indexer SQL.
|
|
24
24
|
export const signature = 'event Transfer(address indexed from, address indexed to, uint256 value)';
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Half-open `[start, end)` bounds of the deterministic TIP-20 contract range.
|
|
27
|
+
* Full-width so the values compare correctly as PostgreSQL bytea, ClickHouse
|
|
28
|
+
* hex strings, and lowercased JavaScript strings.
|
|
29
|
+
*/
|
|
30
|
+
export const tip20AddressRange = [
|
|
28
31
|
'0x20c0000000000000000000000000000000000000',
|
|
29
32
|
'0x20c1000000000000000000000000000000000000',
|
|
30
33
|
];
|
|
34
|
+
// The global feed spans every TIP-20 token, so its newest-first query cannot
|
|
35
|
+
// follow `token_transfers`' token-first sort key. Block windows bound the sort.
|
|
36
|
+
const transferWindowBlocks = 100_000;
|
|
37
|
+
// Sparse global scans retain progress without monopolizing one request.
|
|
38
|
+
const transferWindowScanCap = 8;
|
|
39
|
+
// Recipient-first decoded scans usually fill a page immediately. A larger raw
|
|
40
|
+
// batch leaves room to discard non-TIP-20 `Transfer` events without extra I/O.
|
|
41
|
+
const decodedTransferBatchSize = 256;
|
|
42
|
+
// Bound decoded recipient traversal while preserving the largest positional
|
|
43
|
+
// page and its extra row for `nextCursor` detection.
|
|
44
|
+
const decodedTransferRowCap = Schema.maxPageWindow + 1;
|
|
31
45
|
// A memo'd TIP-20 transfer emits `TransferWithMemo` alongside the plain
|
|
32
46
|
// `Transfer`; the two rows match on `(tx_hash, from, to, amount, token)`. A
|
|
33
47
|
// text memo is decoded into `memo`; an MPP attribution fingerprint memo is
|
|
34
48
|
// resolved into `attribution` instead.
|
|
35
49
|
const memoSignature = 'event TransferWithMemo(address indexed from, address indexed to, uint256 amount, bytes32 indexed memo)';
|
|
36
50
|
const tokenFields = ['token.logoUri', 'token.verified'];
|
|
51
|
+
// Concurrent metadata reads collapse into one deployless multicall. Small
|
|
52
|
+
// chunks keep each call under the Tempo RPC's per-call size limit.
|
|
53
|
+
const tokenReadChunkSize = 10;
|
|
37
54
|
/** Zod schemas owned by the transfer handlers. */
|
|
38
55
|
export var schema;
|
|
39
56
|
(function (schema) {
|
|
@@ -239,8 +256,8 @@ export function transfers(options = {}) {
|
|
|
239
256
|
});
|
|
240
257
|
const options = c.req.valid('query');
|
|
241
258
|
const chainId = options.chainId ?? c.get('chainId');
|
|
242
|
-
const denomination = options['valuation.currency']
|
|
243
|
-
// Cursor pages carry current valuations and curated verification data.
|
|
259
|
+
const denomination = options['valuation.currency'];
|
|
260
|
+
// Cursor pages can carry current valuations and curated verification data.
|
|
244
261
|
if (options.cursor !== undefined)
|
|
245
262
|
Cache.setPolicy(c, Cache.policies.metadata);
|
|
246
263
|
// Filters shared by the page query and the opt-in total count, so the
|
|
@@ -329,49 +346,58 @@ export function transfers(options = {}) {
|
|
|
329
346
|
return suppress.has(key) ? [] : [transfer];
|
|
330
347
|
});
|
|
331
348
|
}
|
|
332
|
-
// Verification is ancillary to the feed. Skip empty pages and degrade
|
|
333
|
-
// cold snapshot failures to null valuations.
|
|
334
|
-
const snapshot = data.length > 0
|
|
335
|
-
? await VerifiedTokens.snapshot(c, chainId).catch(() => undefined)
|
|
336
|
-
: undefined;
|
|
337
349
|
const includeLogoUri = options.include.includes('token.logoUri');
|
|
338
350
|
const includeVerified = options.include.includes('token.verified');
|
|
351
|
+
// Curated data is needed only for requested valuation or token fields.
|
|
352
|
+
const snapshot = data.length > 0 && (denomination !== undefined || includeLogoUri || includeVerified)
|
|
353
|
+
? await VerifiedTokens.snapshot(c, chainId).catch(() => undefined)
|
|
354
|
+
: undefined;
|
|
339
355
|
const uniqueTokens = Array.from(new Set(data.flatMap((transfer) => [
|
|
340
356
|
transfer.sourceToken.address,
|
|
341
357
|
...(transfer.destinationToken ? [transfer.destinationToken.address] : []),
|
|
342
358
|
])));
|
|
343
|
-
const tokensByAddress = new Map(
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
metadata
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
359
|
+
const tokensByAddress = new Map();
|
|
360
|
+
await Timing.time(c, 'tokens', async () => {
|
|
361
|
+
for (let index = 0; index < uniqueTokens.length; index += tokenReadChunkSize) {
|
|
362
|
+
const group = uniqueTokens.slice(index, index + tokenReadChunkSize);
|
|
363
|
+
const entries = await Promise.all(group.map(async (token) => {
|
|
364
|
+
const [logoUri, metadata] = await Promise.all([
|
|
365
|
+
includeLogoUri
|
|
366
|
+
? Timing.time(c, 'token_logo', () => Tokens.getTokenLogo(c, { address: token, chainId })).catch(() => undefined)
|
|
367
|
+
: undefined,
|
|
368
|
+
Timing.time(c, 'token_metadata', () => Tokens.getTokenMetadata(c, { address: token, chainId })),
|
|
369
|
+
]);
|
|
370
|
+
return [
|
|
371
|
+
token.toLowerCase(),
|
|
372
|
+
{
|
|
373
|
+
...metadata,
|
|
374
|
+
logoUri: includeLogoUri
|
|
375
|
+
? (logoUri ??
|
|
376
|
+
snapshot?.byAddress.get(token.toLowerCase())?.logoUri ??
|
|
377
|
+
metadata.logoUri)
|
|
378
|
+
: undefined,
|
|
379
|
+
},
|
|
380
|
+
];
|
|
381
|
+
}));
|
|
382
|
+
for (const entry of entries)
|
|
383
|
+
tokensByAddress.set(...entry);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
362
386
|
// Value each leg via its curated display currency, in the requested
|
|
363
387
|
// denomination.
|
|
364
|
-
const rates =
|
|
365
|
-
|
|
366
|
-
transfer
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
388
|
+
const rates = denomination
|
|
389
|
+
? await Valuation.ratesFor(c, {
|
|
390
|
+
currencies: data.flatMap((transfer) => [
|
|
391
|
+
transfer.sourceToken.address,
|
|
392
|
+
...(transfer.destinationToken ? [transfer.destinationToken.address] : []),
|
|
393
|
+
].flatMap((address) => {
|
|
394
|
+
const held = snapshot?.byAddress.get(address.toLowerCase())?.currency;
|
|
395
|
+
return held === undefined ? [] : [held];
|
|
396
|
+
})),
|
|
397
|
+
denomination,
|
|
398
|
+
oracle,
|
|
399
|
+
})
|
|
400
|
+
: undefined;
|
|
375
401
|
const enrich = (leg) => {
|
|
376
402
|
const metadata = tokensByAddress.get(leg.address.toLowerCase());
|
|
377
403
|
if (!metadata)
|
|
@@ -383,12 +409,16 @@ export function transfers(options = {}) {
|
|
|
383
409
|
currency: metadata.currency,
|
|
384
410
|
decimals: metadata.decimals,
|
|
385
411
|
}),
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
412
|
+
...(denomination
|
|
413
|
+
? {
|
|
414
|
+
valuation: Valuation.valuationFor({
|
|
415
|
+
amount: BigInt(leg.amount),
|
|
416
|
+
denomination,
|
|
417
|
+
rates,
|
|
418
|
+
token: snapshot?.byAddress.get(leg.address.toLowerCase()),
|
|
419
|
+
}),
|
|
420
|
+
}
|
|
421
|
+
: {}),
|
|
392
422
|
},
|
|
393
423
|
token: {
|
|
394
424
|
address: leg.address,
|
|
@@ -463,7 +493,7 @@ export function query(c, options) {
|
|
|
463
493
|
const { chainId, timing, ...rest } = options;
|
|
464
494
|
const store = c.get('store');
|
|
465
495
|
const tidx = c.get('getTidx')(chainId);
|
|
466
|
-
const cursor =
|
|
496
|
+
const cursor = decodeTransferCursor(options.cursor);
|
|
467
497
|
return Timing.time(c, timing, () => Store.memoize(async () => {
|
|
468
498
|
const page = await scan({ tidx }, { chainId, ...rest });
|
|
469
499
|
return {
|
|
@@ -471,13 +501,13 @@ export function query(c, options) {
|
|
|
471
501
|
nextCursor: page.nextCursor,
|
|
472
502
|
};
|
|
473
503
|
}, {
|
|
474
|
-
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}`,
|
|
504
|
+
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}`,
|
|
475
505
|
store,
|
|
476
506
|
ttl: Ttl.seconds(15),
|
|
477
507
|
}));
|
|
478
508
|
}
|
|
479
509
|
/**
|
|
480
|
-
* Context-free core of {@link query}: reads token-scoped rows from
|
|
510
|
+
* Context-free core of {@link query}: reads token-scoped and global rows from
|
|
481
511
|
* `token_transfers`, otherwise the decoded `Transfer` CTE, then paginates and
|
|
482
512
|
* decodes them. Also used by the webhook poller.
|
|
483
513
|
*/
|
|
@@ -489,46 +519,193 @@ export async function scan(deps, options) {
|
|
|
489
519
|
// Keyset pagination: anchor the page below the previous row's `(block, log_idx)`
|
|
490
520
|
// position instead of a numeric offset, so rows arriving at the head can't
|
|
491
521
|
// shift items across pages. A malformed cursor falls back to the head page.
|
|
492
|
-
const cursor =
|
|
522
|
+
const cursor = decodeTransferCursor(options.cursor);
|
|
493
523
|
// Bounded positional lane (exclusive with `cursor` at the schema): page 1 is
|
|
494
524
|
// the head page, so it shares the head's SQL and cache entries; deeper pages
|
|
495
525
|
// translate to a SQL OFFSET of `(page - 1) * limit` rows.
|
|
496
|
-
const
|
|
526
|
+
const pageOffset = options.page !== undefined && options.page > 1 ? (options.page - 1) * limit : undefined;
|
|
527
|
+
const offset = pageOffset ?? (cursor?.offset === 0 ? undefined : cursor?.offset);
|
|
497
528
|
const address = options.eitherSide;
|
|
529
|
+
const addressScoped = address !== undefined;
|
|
530
|
+
const recipient = options.recipient;
|
|
531
|
+
const recipientDecoded = !tokenScoped && recipient !== undefined;
|
|
532
|
+
const decoded = !tokenScoped && (addressScoped || recipientDecoded);
|
|
533
|
+
const splitAddress = addressScoped && !recipientDecoded;
|
|
534
|
+
// Only one-sided `"from"`/`"to"` equality shapes hit the planner's
|
|
535
|
+
// deterministic top-N kill, so only they take the capped fallback.
|
|
536
|
+
const sideFiltered = addressScoped || options.sender !== undefined || options.recipient !== undefined;
|
|
537
|
+
const global = !tokenScoped && !sideFiltered;
|
|
498
538
|
// Transfer events filtered by any combination of contract / sender /
|
|
499
539
|
// recipient / block / timestamp. The address shortcut is split below so
|
|
500
540
|
// each upstream query can use one indexed side.
|
|
501
|
-
const filters =
|
|
502
|
-
|
|
541
|
+
const filters = global
|
|
542
|
+
? [
|
|
543
|
+
`token >= '${tip20AddressRange[0]}'`,
|
|
544
|
+
`token < '${tip20AddressRange[1]}'`,
|
|
545
|
+
...(options.fromBlock !== undefined ? [`block_num >= ${options.fromBlock}`] : []),
|
|
546
|
+
...(options.toBlock !== undefined ? [`block_num <= ${options.toBlock}`] : []),
|
|
547
|
+
...(options.fromTimestamp !== undefined
|
|
548
|
+
? [`block_timestamp >= '${formatClickHouseDateTime(options.fromTimestamp)}'`]
|
|
549
|
+
: []),
|
|
550
|
+
...(options.toTimestamp !== undefined
|
|
551
|
+
? [`block_timestamp <= '${formatClickHouseDateTime(options.toTimestamp)}'`]
|
|
552
|
+
: []),
|
|
553
|
+
]
|
|
554
|
+
: decoded
|
|
555
|
+
? [
|
|
556
|
+
...(options.sender !== undefined ? [`"from" = '${options.sender}'`] : []),
|
|
557
|
+
...(recipient !== undefined ? [`"to" = '${recipient}'`] : []),
|
|
558
|
+
...(options.fromBlock !== undefined ? [`block_num >= ${options.fromBlock}`] : []),
|
|
559
|
+
...(options.toBlock !== undefined ? [`block_num <= ${options.toBlock}`] : []),
|
|
560
|
+
...(options.fromTimestamp !== undefined
|
|
561
|
+
? [`block_timestamp >= '${options.fromTimestamp}'`]
|
|
562
|
+
: []),
|
|
563
|
+
...(options.toTimestamp !== undefined
|
|
564
|
+
? [`block_timestamp <= '${options.toTimestamp}'`]
|
|
565
|
+
: []),
|
|
566
|
+
]
|
|
567
|
+
: filterClauses(options);
|
|
568
|
+
if (cursor !== undefined && !decoded)
|
|
503
569
|
filters.push(Cursor.keyset([
|
|
504
|
-
{ literal: Cursor.literal(cursor[0], 'int'), name: 'block_num', order },
|
|
505
|
-
{ literal: Cursor.literal(cursor[1], 'int'), name: 'log_idx', order },
|
|
570
|
+
{ literal: Cursor.literal(cursor.position[0], 'int'), name: 'block_num', order },
|
|
571
|
+
{ literal: Cursor.literal(cursor.position[1], 'int'), name: 'log_idx', order },
|
|
506
572
|
]));
|
|
507
|
-
const addressScoped = address !== undefined;
|
|
508
573
|
const window = addressScoped ? (offset ?? 0) + limit + 1 : limit + 1;
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
const sideFiltered = addressScoped || options.sender !== undefined || options.recipient !== undefined;
|
|
512
|
-
const fetchRows = async (side) => {
|
|
574
|
+
const fetchRows = async (options_fetch = {}) => {
|
|
575
|
+
const side = options_fetch.side;
|
|
513
576
|
const sideFilters = side !== undefined && address !== undefined
|
|
514
577
|
? [`"${side}" = '${address}'`, ...filters]
|
|
515
578
|
: filters;
|
|
516
|
-
const
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
579
|
+
const queryFilters = [
|
|
580
|
+
...sideFilters,
|
|
581
|
+
...(options_fetch.lo !== undefined ? [`block_num >= ${options_fetch.lo}`] : []),
|
|
582
|
+
...(options_fetch.hi !== undefined ? [`block_num <= ${options_fetch.hi}`] : []),
|
|
583
|
+
];
|
|
584
|
+
const materialized = tokenScoped || global;
|
|
585
|
+
// Keep a distinct alias until tidx.ts parses ClickHouse timestamps as UTC.
|
|
586
|
+
const select = materialized
|
|
587
|
+
? `"from", "to", token AS address, amount AS value, tx_hash, block_num, log_idx, toString(block_timestamp, 'UTC') AS block_timestamp_utc`
|
|
588
|
+
: `"from", "to", address, value, tx_hash, block_num, log_idx, block_timestamp`;
|
|
589
|
+
const table = materialized ? 'token_transfers' : 'Transfer';
|
|
590
|
+
const execute = async (filters_execute, queryLimit, queryOffset) => {
|
|
591
|
+
const where = filters_execute.length > 0 ? `WHERE ${filters_execute.join(' AND ')}` : '';
|
|
520
592
|
const result = await tidx.fetch({
|
|
521
593
|
chainId,
|
|
522
|
-
...(
|
|
594
|
+
...(materialized ? { engine: 'clickhouse' } : { signatures: [signature] }),
|
|
523
595
|
query: `
|
|
524
596
|
SELECT ${select}
|
|
525
597
|
FROM ${table}
|
|
526
598
|
${where}
|
|
527
599
|
ORDER BY block_num ${direction}, log_idx ${direction}
|
|
528
|
-
LIMIT ${
|
|
600
|
+
LIMIT ${queryLimit}${queryOffset !== undefined ? ` OFFSET ${queryOffset}` : ''}
|
|
529
601
|
`,
|
|
530
602
|
});
|
|
531
|
-
return
|
|
603
|
+
return result.rows;
|
|
604
|
+
};
|
|
605
|
+
if (decoded) {
|
|
606
|
+
const target = (offset ?? 0) + limit + 1;
|
|
607
|
+
// Filter the physical token column so ClickHouse can use the materialized table ordering.
|
|
608
|
+
const filters_materialized = [
|
|
609
|
+
`token >= '${tip20AddressRange[0]}'`,
|
|
610
|
+
`token < '${tip20AddressRange[1]}'`,
|
|
611
|
+
...(options.sender !== undefined ? [`"from" = '${options.sender}'`] : []),
|
|
612
|
+
...(recipient !== undefined ? [`"to" = '${recipient}'`] : []),
|
|
613
|
+
...(options.fromBlock !== undefined ? [`block_num >= ${options.fromBlock}`] : []),
|
|
614
|
+
...(options.toBlock !== undefined ? [`block_num <= ${options.toBlock}`] : []),
|
|
615
|
+
...(options.fromTimestamp !== undefined
|
|
616
|
+
? [`block_timestamp >= '${formatClickHouseDateTime(options.fromTimestamp)}'`]
|
|
617
|
+
: []),
|
|
618
|
+
...(options.toTimestamp !== undefined
|
|
619
|
+
? [`block_timestamp <= '${formatClickHouseDateTime(options.toTimestamp)}'`]
|
|
620
|
+
: []),
|
|
621
|
+
];
|
|
622
|
+
if (cursor !== undefined)
|
|
623
|
+
filters_materialized.push(Cursor.keyset([
|
|
624
|
+
{ literal: Cursor.literal(cursor.position[0], 'int'), name: 'block_num', order },
|
|
625
|
+
{ literal: Cursor.literal(cursor.position[1], 'int'), name: 'log_idx', order },
|
|
626
|
+
]));
|
|
627
|
+
const sideFilters_materialized = side !== undefined && address !== undefined
|
|
628
|
+
? [`"${side}" = '${address}'`, ...filters_materialized]
|
|
629
|
+
: filters_materialized;
|
|
630
|
+
const where_materialized = sideFilters_materialized.length > 0 ? `WHERE ${sideFilters_materialized.join(' AND ')}` : '';
|
|
631
|
+
try {
|
|
632
|
+
const result = await tidx.fetch({
|
|
633
|
+
chainId,
|
|
634
|
+
engine: 'clickhouse',
|
|
635
|
+
query: `
|
|
636
|
+
SELECT DISTINCT "from", "to", token AS address, amount AS value, tx_hash, block_num, log_idx, toString(block_timestamp, 'UTC') AS block_timestamp_utc
|
|
637
|
+
FROM token_transfers
|
|
638
|
+
${where_materialized}
|
|
639
|
+
ORDER BY block_num ${direction}, log_idx ${direction}
|
|
640
|
+
LIMIT ${target}
|
|
641
|
+
`,
|
|
642
|
+
});
|
|
643
|
+
return {
|
|
644
|
+
capped: false,
|
|
645
|
+
rows: result.rows.slice(splitAddress ? 0 : (offset ?? 0), target),
|
|
646
|
+
sample: false,
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
catch (error) {
|
|
650
|
+
// Older chains can reject materialized address scans, so retain the
|
|
651
|
+
// bounded decoded scan as a compatibility fallback.
|
|
652
|
+
if (!Tidx.isQueryRejection(error))
|
|
653
|
+
throw error;
|
|
654
|
+
}
|
|
655
|
+
const rows = [];
|
|
656
|
+
let anchor = cursor?.position;
|
|
657
|
+
let rawRowCount = 0;
|
|
658
|
+
while (rows.length < target && rawRowCount < decodedTransferRowCap) {
|
|
659
|
+
const batchLimit = Math.min(Math.max(target - rows.length, decodedTransferBatchSize), Schema.maxPageWindow, decodedTransferRowCap - rawRowCount);
|
|
660
|
+
const batch = await (async () => {
|
|
661
|
+
if (anchor === undefined)
|
|
662
|
+
return execute(queryFilters, batchLimit);
|
|
663
|
+
// Signature-decoded queries reject or mis-plan tuple keysets. Read
|
|
664
|
+
// the cursor block and strict outer block range separately.
|
|
665
|
+
const block = Cursor.literal(anchor[0], 'int');
|
|
666
|
+
const index = Cursor.literal(anchor[1], 'int');
|
|
667
|
+
const comparison = order === 'asc' ? '>' : '<';
|
|
668
|
+
const sameBlock = await execute([...queryFilters, `block_num = ${block}`, `log_idx ${comparison} ${index}`], batchLimit);
|
|
669
|
+
if (sameBlock.length >= batchLimit)
|
|
670
|
+
return sameBlock;
|
|
671
|
+
const outerBlocks = await execute([...queryFilters, `block_num ${comparison} ${block}`], batchLimit - sameBlock.length);
|
|
672
|
+
return [...sameBlock, ...outerBlocks];
|
|
673
|
+
})();
|
|
674
|
+
rawRowCount += batch.length;
|
|
675
|
+
rows.push(...batch.filter(isTip20TransferRow));
|
|
676
|
+
if (batch.length < batchLimit)
|
|
677
|
+
break;
|
|
678
|
+
const last = batch.at(-1);
|
|
679
|
+
const block = Value.toNumber(last?.['block_num']);
|
|
680
|
+
const index = Value.toNumber(last?.['log_idx']);
|
|
681
|
+
if (block === undefined || index === undefined)
|
|
682
|
+
break;
|
|
683
|
+
anchor = [block, index];
|
|
684
|
+
}
|
|
685
|
+
const capped = rows.length < target && rawRowCount >= decodedTransferRowCap;
|
|
686
|
+
return {
|
|
687
|
+
capped,
|
|
688
|
+
...(capped && anchor !== undefined
|
|
689
|
+
? {
|
|
690
|
+
resume: {
|
|
691
|
+
offset: Math.max(0, (offset ?? 0) - rows.length),
|
|
692
|
+
position: anchor,
|
|
693
|
+
},
|
|
694
|
+
}
|
|
695
|
+
: {}),
|
|
696
|
+
rows: rows.slice(splitAddress ? 0 : (offset ?? 0), target),
|
|
697
|
+
sample: false,
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
const queryLimit = options_fetch.limit ?? window;
|
|
701
|
+
const queryOffset = options_fetch.offset ?? (!addressScoped ? offset : undefined);
|
|
702
|
+
const where = queryFilters.length > 0 ? `WHERE ${queryFilters.join(' AND ')}` : '';
|
|
703
|
+
try {
|
|
704
|
+
return {
|
|
705
|
+
capped: false,
|
|
706
|
+
rows: await execute(queryFilters, queryLimit, queryOffset),
|
|
707
|
+
sample: false,
|
|
708
|
+
};
|
|
532
709
|
}
|
|
533
710
|
catch (error) {
|
|
534
711
|
// The planner kills a one-sided top-N walk when its matches sit far
|
|
@@ -538,7 +715,7 @@ export async function scan(deps, options) {
|
|
|
538
715
|
throw error;
|
|
539
716
|
const result = await tidx.fetch({
|
|
540
717
|
chainId,
|
|
541
|
-
...(
|
|
718
|
+
...(materialized ? { engine: 'clickhouse' } : { signatures: [signature] }),
|
|
542
719
|
query: `
|
|
543
720
|
SELECT ${select}
|
|
544
721
|
FROM ${table}
|
|
@@ -546,15 +723,84 @@ export async function scan(deps, options) {
|
|
|
546
723
|
LIMIT ${Schema.countCap}
|
|
547
724
|
`,
|
|
548
725
|
});
|
|
549
|
-
return { rows: result.rows, sample: true };
|
|
726
|
+
return { capped: result.rows.length >= Schema.countCap, rows: result.rows, sample: true };
|
|
550
727
|
}
|
|
551
728
|
};
|
|
729
|
+
const windowed = global;
|
|
730
|
+
const fetchWindowedRows = async () => {
|
|
731
|
+
const indexedCeiling = options.toTimestamp === undefined
|
|
732
|
+
? await indexedHead(tidx, { chainId })
|
|
733
|
+
: await indexedBlockAtOrBefore(tidx, { chainId, timestamp: options.toTimestamp });
|
|
734
|
+
if (indexedCeiling === undefined)
|
|
735
|
+
return { capped: false, rows: [], sample: false };
|
|
736
|
+
const floor = options.fromBlock ?? 0;
|
|
737
|
+
const ceiling = Math.min(options.toBlock ?? indexedCeiling, indexedCeiling);
|
|
738
|
+
const target = (offset ?? 0) + limit + 1;
|
|
739
|
+
const rows = [];
|
|
740
|
+
const cursorBlock = cursor?.position[0];
|
|
741
|
+
let resumePosition;
|
|
742
|
+
let scannedWindows = 0;
|
|
743
|
+
let anchor = order === 'asc'
|
|
744
|
+
? Math.max(floor, cursorBlock ?? floor)
|
|
745
|
+
: Math.min(ceiling, cursorBlock ?? ceiling);
|
|
746
|
+
while (anchor >= floor &&
|
|
747
|
+
anchor <= ceiling &&
|
|
748
|
+
rows.length < target &&
|
|
749
|
+
scannedWindows < transferWindowScanCap) {
|
|
750
|
+
const window_lo = order === 'asc' ? anchor : Math.max(floor, anchor - transferWindowBlocks + 1);
|
|
751
|
+
const window_hi = order === 'desc' ? anchor : Math.min(ceiling, anchor + transferWindowBlocks - 1);
|
|
752
|
+
let windowOffset = 0;
|
|
753
|
+
while (rows.length < target) {
|
|
754
|
+
const queryLimit = Math.min(target - rows.length, Schema.maxPageWindow);
|
|
755
|
+
const result = await fetchRows({
|
|
756
|
+
hi: window_hi,
|
|
757
|
+
limit: queryLimit,
|
|
758
|
+
lo: window_lo,
|
|
759
|
+
offset: windowOffset,
|
|
760
|
+
});
|
|
761
|
+
rows.push(...result.rows);
|
|
762
|
+
windowOffset += result.rows.length;
|
|
763
|
+
if (result.rows.length < queryLimit)
|
|
764
|
+
break;
|
|
765
|
+
}
|
|
766
|
+
scannedWindows++;
|
|
767
|
+
// Resume after the fully scanned boundary without skipping the next block.
|
|
768
|
+
resumePosition = order === 'asc' ? [window_hi, Number.MAX_SAFE_INTEGER] : [window_lo, 0];
|
|
769
|
+
anchor = order === 'asc' ? window_hi + 1 : window_lo - 1;
|
|
770
|
+
}
|
|
771
|
+
const capped = rows.length < target &&
|
|
772
|
+
anchor >= floor &&
|
|
773
|
+
anchor <= ceiling &&
|
|
774
|
+
scannedWindows >= transferWindowScanCap;
|
|
775
|
+
return {
|
|
776
|
+
capped,
|
|
777
|
+
...(capped && resumePosition !== undefined
|
|
778
|
+
? {
|
|
779
|
+
resume: {
|
|
780
|
+
offset: Math.max(0, (offset ?? 0) - rows.length),
|
|
781
|
+
position: resumePosition,
|
|
782
|
+
},
|
|
783
|
+
}
|
|
784
|
+
: {}),
|
|
785
|
+
rows: rows.slice(offset ?? 0),
|
|
786
|
+
sample: false,
|
|
787
|
+
};
|
|
788
|
+
};
|
|
789
|
+
// With `to = recipient`, the either-side address is redundant when equal
|
|
790
|
+
// and otherwise reduces to `from = address`, so one ordered stream suffices.
|
|
791
|
+
const recipientAddressSide = recipientDecoded && address !== undefined && address !== recipient ? 'from' : undefined;
|
|
552
792
|
// TIDX cannot plan `(from = X OR to = X)` over the decoded Transfer table.
|
|
553
|
-
// Fetch one
|
|
554
|
-
const fetched =
|
|
555
|
-
? await
|
|
556
|
-
:
|
|
557
|
-
|
|
793
|
+
// Fetch one stream per side only when windowing or recipient reduction does not apply.
|
|
794
|
+
const fetched = windowed
|
|
795
|
+
? [await fetchWindowedRows()]
|
|
796
|
+
: splitAddress
|
|
797
|
+
? await Promise.all([fetchRows({ side: 'from' }), fetchRows({ side: 'to' })])
|
|
798
|
+
: [await fetchRows(recipientAddressSide === undefined ? {} : { side: recipientAddressSide })];
|
|
799
|
+
const { capped, resume, rows } = collate(fetched, {
|
|
800
|
+
limit: limit + 1,
|
|
801
|
+
offset: offset ?? 0,
|
|
802
|
+
order,
|
|
803
|
+
});
|
|
558
804
|
// The next page anchors below the last fetched row's `(block, log_idx)`.
|
|
559
805
|
const page = Cursor.paginate({
|
|
560
806
|
rows,
|
|
@@ -574,7 +820,7 @@ export async function scan(deps, options) {
|
|
|
574
820
|
const amount = Value.toIntegerString(row['value']);
|
|
575
821
|
const blockNumber = Value.toNumber(row['block_num']);
|
|
576
822
|
const logIndex = Value.toNumber(row['log_idx']);
|
|
577
|
-
const timestamp = Value.toIsoDateTime(row['block_timestamp']);
|
|
823
|
+
const timestamp = Value.toIsoDateTime(row['block_timestamp_utc'] ?? row['block_timestamp']);
|
|
578
824
|
if (tokenAddress.success &&
|
|
579
825
|
from.success &&
|
|
580
826
|
to.success &&
|
|
@@ -597,29 +843,103 @@ export async function scan(deps, options) {
|
|
|
597
843
|
},
|
|
598
844
|
});
|
|
599
845
|
}
|
|
600
|
-
//
|
|
601
|
-
//
|
|
846
|
+
// Every matching row through `resume.position` was inspected, so advancing
|
|
847
|
+
// there cannot skip a match. Positional continuations retain their offset.
|
|
848
|
+
const nextCursor = resume
|
|
849
|
+
? Cursor.encode(resume.offset > 0 ? [...resume.position, resume.offset] : [...resume.position])
|
|
850
|
+
: capped
|
|
851
|
+
? null
|
|
852
|
+
: page.nextCursor;
|
|
602
853
|
return {
|
|
603
854
|
capped,
|
|
604
|
-
hasMore: capped ? false : page.hasMore,
|
|
855
|
+
hasMore: resume ? true : capped ? false : page.hasMore,
|
|
605
856
|
items,
|
|
606
857
|
limit,
|
|
607
|
-
nextCursor
|
|
858
|
+
nextCursor,
|
|
859
|
+
...(resume ? { resumeCursor: resume.position } : {}),
|
|
608
860
|
};
|
|
609
861
|
}
|
|
862
|
+
/** Reads the latest block available to TIDX for a descending window scan. */
|
|
863
|
+
async function indexedHead(tidx, options) {
|
|
864
|
+
const result = await tidx.fetch({
|
|
865
|
+
chainId: options.chainId,
|
|
866
|
+
query: 'SELECT num FROM blocks ORDER BY num DESC LIMIT 1',
|
|
867
|
+
});
|
|
868
|
+
return Value.toNumber(result.rows[0]?.['num']);
|
|
869
|
+
}
|
|
870
|
+
/** Reads the latest indexed block at or before a timestamp. */
|
|
871
|
+
async function indexedBlockAtOrBefore(tidx, options) {
|
|
872
|
+
const { chainId, timestamp } = options;
|
|
873
|
+
const result = await tidx.fetch({
|
|
874
|
+
chainId,
|
|
875
|
+
query: `
|
|
876
|
+
SELECT num
|
|
877
|
+
FROM blocks
|
|
878
|
+
WHERE timestamp <= '${Tidx.escape(timestamp)}'
|
|
879
|
+
ORDER BY num DESC
|
|
880
|
+
LIMIT 1
|
|
881
|
+
`,
|
|
882
|
+
});
|
|
883
|
+
return Value.toNumber(result.rows[0]?.['num']);
|
|
884
|
+
}
|
|
885
|
+
/** Formats an ISO timestamp for ClickHouse `DateTime64` comparisons. */
|
|
886
|
+
function formatClickHouseDateTime(value) {
|
|
887
|
+
return value.replace('T', ' ').replace(/Z$/, '');
|
|
888
|
+
}
|
|
610
889
|
/**
|
|
611
890
|
* Collates per-side transfer streams into one ordered page window: split and
|
|
612
891
|
* sampled streams are deduped, globally ordered, and paged app-side, while a
|
|
613
|
-
* lone engine-paged stream passes through.
|
|
614
|
-
*
|
|
615
|
-
* unfit to anchor pagination or exact counts.
|
|
892
|
+
* lone engine-paged stream passes through. Ordered capped streams may carry a
|
|
893
|
+
* safe raw continuation; unordered samples cannot.
|
|
616
894
|
*/
|
|
617
895
|
export function collate(fetched, options) {
|
|
618
|
-
const capped = fetched.some((
|
|
896
|
+
const capped = fetched.some((stream) => stream.capped);
|
|
897
|
+
const cappedStreams = fetched.filter((stream) => stream.capped);
|
|
898
|
+
const resumes = cappedStreams.flatMap((stream) => (stream.resume ? [stream.resume] : []));
|
|
899
|
+
if (fetched.length > 1 &&
|
|
900
|
+
capped &&
|
|
901
|
+
!fetched.some((stream) => stream.sample) &&
|
|
902
|
+
resumes.length === cappedStreams.length) {
|
|
903
|
+
// Advance only through the least-progressed side, so the next strict
|
|
904
|
+
// keyset scan cannot rediscover any returned rows.
|
|
905
|
+
const position = resumes
|
|
906
|
+
.map((resume) => resume.position)
|
|
907
|
+
.reduce((candidate, current) => {
|
|
908
|
+
const comparison = candidate[0] - current[0] || candidate[1] - current[1];
|
|
909
|
+
if (options.order === 'asc')
|
|
910
|
+
return comparison <= 0 ? candidate : current;
|
|
911
|
+
return comparison >= 0 ? candidate : current;
|
|
912
|
+
});
|
|
913
|
+
const safeRows = fetched.flatMap((stream) => stream.rows.filter((row) => {
|
|
914
|
+
const blockNumber = Value.toNumber(row['block_num']);
|
|
915
|
+
const logIndex = Value.toNumber(row['log_idx']);
|
|
916
|
+
if (blockNumber === undefined || logIndex === undefined)
|
|
917
|
+
return false;
|
|
918
|
+
const comparison = blockNumber - position[0] || logIndex - position[1];
|
|
919
|
+
return options.order === 'asc' ? comparison <= 0 : comparison >= 0;
|
|
920
|
+
}));
|
|
921
|
+
const merged = mergeTransferRows(safeRows, {
|
|
922
|
+
limit: options.offset + options.limit,
|
|
923
|
+
offset: 0,
|
|
924
|
+
order: options.order,
|
|
925
|
+
});
|
|
926
|
+
const rows = merged.slice(options.offset, options.offset + options.limit);
|
|
927
|
+
if (rows.length >= options.limit)
|
|
928
|
+
return { capped: false, rows };
|
|
929
|
+
return {
|
|
930
|
+
capped: true,
|
|
931
|
+
resume: {
|
|
932
|
+
offset: Math.max(0, options.offset - merged.length),
|
|
933
|
+
position,
|
|
934
|
+
},
|
|
935
|
+
rows,
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
const resume = fetched.length === 1 ? fetched[0]?.resume : undefined;
|
|
619
939
|
const rows = fetched.length > 1 || fetched.some(({ sample }) => sample)
|
|
620
940
|
? mergeTransferRows(fetched.flatMap(({ rows }) => rows), options)
|
|
621
941
|
: (fetched[0]?.rows ?? []);
|
|
622
|
-
return { capped, rows };
|
|
942
|
+
return { capped, ...(resume ? { resume } : {}), rows };
|
|
623
943
|
}
|
|
624
944
|
/** Deduplicates and globally orders bounded `from`/`to` transfer streams. */
|
|
625
945
|
function mergeTransferRows(rows, options) {
|
|
@@ -643,12 +963,25 @@ function mergeTransferRows(rows, options) {
|
|
|
643
963
|
});
|
|
644
964
|
return positioned.slice(options.offset, options.offset + options.limit).map(({ row }) => row);
|
|
645
965
|
}
|
|
966
|
+
/** Decodes ordinary transfer cursors and capped positional continuations. */
|
|
967
|
+
function decodeTransferCursor(token) {
|
|
968
|
+
if (token === undefined)
|
|
969
|
+
return undefined;
|
|
970
|
+
const position = Cursor.decode(token, ['int', 'int']);
|
|
971
|
+
if (position)
|
|
972
|
+
return { offset: 0, position: position };
|
|
973
|
+
const continuation = Cursor.decode(token, ['int', 'int', 'int']);
|
|
974
|
+
if (!continuation)
|
|
975
|
+
return undefined;
|
|
976
|
+
const [blockNumber, logIndex, offset] = continuation;
|
|
977
|
+
return { offset: Math.max(0, offset), position: [blockNumber, logIndex] };
|
|
978
|
+
}
|
|
646
979
|
/**
|
|
647
980
|
* Builds shared transfer filters. Token-scoped queries target `token_transfers`;
|
|
648
981
|
* unscoped queries constrain the decoded `Transfer` emitter to TIP-20 contracts.
|
|
649
982
|
*/
|
|
650
983
|
function filterClauses(options) {
|
|
651
|
-
const { fromBlock, fromTimestamp, recipient, sender, toBlock,
|
|
984
|
+
const { fromBlock, fromTimestamp, recipient, sender, toBlock, token, toTimestamp } = options;
|
|
652
985
|
const filters = token === undefined
|
|
653
986
|
? [`address >= '${tip20AddressRange[0]}'`, `address < '${tip20AddressRange[1]}'`]
|
|
654
987
|
: [`token = '${token}'`];
|
|
@@ -666,6 +999,13 @@ function filterClauses(options) {
|
|
|
666
999
|
filters.push(`block_timestamp <= '${toTimestamp}'`);
|
|
667
1000
|
return filters;
|
|
668
1001
|
}
|
|
1002
|
+
function isTip20TransferRow(row) {
|
|
1003
|
+
const address = row['address'];
|
|
1004
|
+
if (typeof address !== 'string')
|
|
1005
|
+
return false;
|
|
1006
|
+
const normalized = address.toLowerCase();
|
|
1007
|
+
return normalized >= tip20AddressRange[0] && normalized < tip20AddressRange[1];
|
|
1008
|
+
}
|
|
669
1009
|
/**
|
|
670
1010
|
* Capped total-row count for the `Transfer` feed, sharing {@link query}'s
|
|
671
1011
|
* filters (minus pagination) so the count matches the page it annotates. Timed
|
|
@@ -698,14 +1038,181 @@ export async function countScan(deps, options) {
|
|
|
698
1038
|
// rows, classify each transaction, and subtract the folded-away legs. Bounded
|
|
699
1039
|
// by `countCap` like the raw count, so it stays within the indexer's budget.
|
|
700
1040
|
if (options.crossToken) {
|
|
1041
|
+
const global = !tokenScoped &&
|
|
1042
|
+
address === undefined &&
|
|
1043
|
+
options.sender === undefined &&
|
|
1044
|
+
options.recipient === undefined;
|
|
1045
|
+
if (global) {
|
|
1046
|
+
// A capped count promises a conservative lower bound, so aggregate the
|
|
1047
|
+
// deduplicated capped sample without the global block sort or 10,000-row response.
|
|
1048
|
+
const materializedFilters = [
|
|
1049
|
+
`token >= '${tip20AddressRange[0]}'`,
|
|
1050
|
+
`token < '${tip20AddressRange[1]}'`,
|
|
1051
|
+
...(options.fromBlock !== undefined ? [`block_num >= ${options.fromBlock}`] : []),
|
|
1052
|
+
...(options.toBlock !== undefined ? [`block_num <= ${options.toBlock}`] : []),
|
|
1053
|
+
...(options.fromTimestamp !== undefined
|
|
1054
|
+
? [`block_timestamp >= '${options.fromTimestamp}'`]
|
|
1055
|
+
: []),
|
|
1056
|
+
...(options.toTimestamp !== undefined
|
|
1057
|
+
? [`block_timestamp <= '${options.toTimestamp}'`]
|
|
1058
|
+
: []),
|
|
1059
|
+
];
|
|
1060
|
+
const result = await tidx.fetch({
|
|
1061
|
+
chainId,
|
|
1062
|
+
engine: 'clickhouse',
|
|
1063
|
+
query: `
|
|
1064
|
+
SELECT
|
|
1065
|
+
count(*) AS total,
|
|
1066
|
+
countIf(
|
|
1067
|
+
"to" = '${Addresses.stablecoinDex}'
|
|
1068
|
+
OR "from" = '${Addresses.stablecoinDex}'
|
|
1069
|
+
OR "to" = '${Addresses.feeManager}'
|
|
1070
|
+
) AS suppressed
|
|
1071
|
+
FROM (
|
|
1072
|
+
SELECT tx_hash, log_idx, "from", "to"
|
|
1073
|
+
FROM token_transfers FINAL
|
|
1074
|
+
WHERE ${materializedFilters.join(' AND ')}
|
|
1075
|
+
LIMIT ${Schema.countCap}
|
|
1076
|
+
) AS capped
|
|
1077
|
+
`,
|
|
1078
|
+
});
|
|
1079
|
+
const total = Value.toNumber(result.rows[0]?.['total']) ?? 0;
|
|
1080
|
+
const suppressed = Value.toNumber(result.rows[0]?.['suppressed']) ?? 0;
|
|
1081
|
+
if (total >= Schema.countCap) {
|
|
1082
|
+
return {
|
|
1083
|
+
totalCount: Math.max(0, total - suppressed),
|
|
1084
|
+
totalCountCapped: true,
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
if (suppressed === 0)
|
|
1088
|
+
return { totalCount: total, totalCountCapped: false };
|
|
1089
|
+
}
|
|
1090
|
+
if (address !== undefined) {
|
|
1091
|
+
const materializedFilters = [
|
|
1092
|
+
...(options.token === undefined
|
|
1093
|
+
? [`token >= '${tip20AddressRange[0]}'`, `token < '${tip20AddressRange[1]}'`]
|
|
1094
|
+
: [`token = '${options.token}'`]),
|
|
1095
|
+
...(options.sender !== undefined ? [`"from" = '${options.sender}'`] : []),
|
|
1096
|
+
...(options.recipient !== undefined ? [`"to" = '${options.recipient}'`] : []),
|
|
1097
|
+
...(options.fromBlock !== undefined ? [`block_num >= ${options.fromBlock}`] : []),
|
|
1098
|
+
...(options.toBlock !== undefined ? [`block_num <= ${options.toBlock}`] : []),
|
|
1099
|
+
...(options.fromTimestamp !== undefined
|
|
1100
|
+
? [`block_timestamp >= '${options.fromTimestamp}'`]
|
|
1101
|
+
: []),
|
|
1102
|
+
...(options.toTimestamp !== undefined
|
|
1103
|
+
? [`block_timestamp <= '${options.toTimestamp}'`]
|
|
1104
|
+
: []),
|
|
1105
|
+
];
|
|
1106
|
+
const sentFilters = [`"from" = '${address}'`, ...materializedFilters];
|
|
1107
|
+
const receivedFilters = [
|
|
1108
|
+
`"to" = '${address}'`,
|
|
1109
|
+
`"from" != '${address}'`,
|
|
1110
|
+
...materializedFilters,
|
|
1111
|
+
];
|
|
1112
|
+
const aggregate = await tidx.fetch({
|
|
1113
|
+
chainId,
|
|
1114
|
+
engine: 'clickhouse',
|
|
1115
|
+
query: `
|
|
1116
|
+
SELECT
|
|
1117
|
+
'sent' AS side,
|
|
1118
|
+
count(*) AS total,
|
|
1119
|
+
countIf(
|
|
1120
|
+
"to" = '${Addresses.stablecoinDex}'
|
|
1121
|
+
OR "from" = '${Addresses.stablecoinDex}'
|
|
1122
|
+
OR "to" = '${Addresses.feeManager}'
|
|
1123
|
+
) AS suppressed
|
|
1124
|
+
FROM (
|
|
1125
|
+
SELECT "from", "to"
|
|
1126
|
+
FROM token_transfers
|
|
1127
|
+
WHERE ${sentFilters.join(' AND ')}
|
|
1128
|
+
LIMIT ${Schema.countCap}
|
|
1129
|
+
) AS sent
|
|
1130
|
+
UNION ALL
|
|
1131
|
+
SELECT
|
|
1132
|
+
'received' AS side,
|
|
1133
|
+
count(*) AS total,
|
|
1134
|
+
countIf(
|
|
1135
|
+
"to" = '${Addresses.stablecoinDex}'
|
|
1136
|
+
OR "from" = '${Addresses.stablecoinDex}'
|
|
1137
|
+
OR "to" = '${Addresses.feeManager}'
|
|
1138
|
+
) AS suppressed
|
|
1139
|
+
FROM (
|
|
1140
|
+
SELECT "from", "to"
|
|
1141
|
+
FROM token_transfers
|
|
1142
|
+
WHERE ${receivedFilters.join(' AND ')}
|
|
1143
|
+
LIMIT ${Schema.countCap}
|
|
1144
|
+
) AS received
|
|
1145
|
+
`,
|
|
1146
|
+
});
|
|
1147
|
+
const sent = aggregate.rows.find((row) => Value.toText(row['side']) === 'sent');
|
|
1148
|
+
const received = aggregate.rows.find((row) => Value.toText(row['side']) === 'received');
|
|
1149
|
+
const sentTotal = Value.toNumber(sent?.['total']) ?? 0;
|
|
1150
|
+
const receivedTotal = Value.toNumber(received?.['total']) ?? 0;
|
|
1151
|
+
const total = sentTotal + receivedTotal;
|
|
1152
|
+
const suppressed = (Value.toNumber(sent?.['suppressed']) ?? 0) +
|
|
1153
|
+
(Value.toNumber(received?.['suppressed']) ?? 0);
|
|
1154
|
+
const capped = sentTotal >= Schema.countCap || receivedTotal >= Schema.countCap || total >= Schema.countCap;
|
|
1155
|
+
if (capped)
|
|
1156
|
+
return {
|
|
1157
|
+
totalCount: Math.min(Math.max(0, total - suppressed), Schema.countCap),
|
|
1158
|
+
totalCountCapped: true,
|
|
1159
|
+
};
|
|
1160
|
+
if (suppressed === 0)
|
|
1161
|
+
return { totalCount: total, totalCountCapped: false };
|
|
1162
|
+
// A below-cap aggregate proves these unsorted scans are complete, so the
|
|
1163
|
+
// exact bundle classifier does not need a global sort.
|
|
1164
|
+
const select = 'tx_hash, block_num, log_idx, "from", "to", token AS address, amount AS value';
|
|
1165
|
+
const fetchRows = async (sideFilters) => {
|
|
1166
|
+
const result = await tidx.fetch({
|
|
1167
|
+
chainId,
|
|
1168
|
+
engine: 'clickhouse',
|
|
1169
|
+
query: `
|
|
1170
|
+
SELECT ${select}
|
|
1171
|
+
FROM token_transfers
|
|
1172
|
+
WHERE ${sideFilters.join(' AND ')}
|
|
1173
|
+
LIMIT ${Schema.countCap}
|
|
1174
|
+
`,
|
|
1175
|
+
});
|
|
1176
|
+
return result.rows;
|
|
1177
|
+
};
|
|
1178
|
+
const [sentRows, receivedRows] = await Promise.all([
|
|
1179
|
+
fetchRows(sentFilters),
|
|
1180
|
+
fetchRows(receivedFilters),
|
|
1181
|
+
]);
|
|
1182
|
+
const rows = [...sentRows, ...receivedRows];
|
|
1183
|
+
const transactionHashes = [...legsByTransaction(rows)]
|
|
1184
|
+
.filter(([, legs]) => legs.some(isBundleLeg))
|
|
1185
|
+
.map(([transactionHash]) => transactionHash);
|
|
1186
|
+
const transactionHashSet = new Set(transactionHashes);
|
|
1187
|
+
const candidateRows = rows.filter((row) => {
|
|
1188
|
+
const transactionHash = Value.toText(row['tx_hash']);
|
|
1189
|
+
return (transactionHash !== undefined && transactionHashSet.has(transactionHash.toLowerCase()));
|
|
1190
|
+
});
|
|
1191
|
+
const blocks = candidateRows.flatMap((row) => {
|
|
1192
|
+
const block = Value.toNumber(row['block_num']);
|
|
1193
|
+
return block === undefined ? [] : [block];
|
|
1194
|
+
});
|
|
1195
|
+
if (blocks.length === 0 || transactionHashes.length === 0)
|
|
1196
|
+
return foldCount(rows, { capped: false });
|
|
1197
|
+
// Address predicates omit sibling bundle legs. Load each candidate
|
|
1198
|
+
// transaction completely, then suppress only address-matched rows.
|
|
1199
|
+
const classification = await tidx.fetch({
|
|
1200
|
+
chainId,
|
|
1201
|
+
engine: 'clickhouse',
|
|
1202
|
+
query: `
|
|
1203
|
+
SELECT tx_hash, block_num, log_idx, "from", "to", token AS address, amount AS value
|
|
1204
|
+
FROM token_transfers
|
|
1205
|
+
WHERE block_num BETWEEN ${Math.min(...blocks)} AND ${Math.max(...blocks)}
|
|
1206
|
+
AND tx_hash IN (${transactionHashes.map((hash) => `'${hash}'`).join(', ')})
|
|
1207
|
+
`,
|
|
1208
|
+
});
|
|
1209
|
+
return foldCount(rows, { capped: false, classificationRows: classification.rows });
|
|
1210
|
+
}
|
|
701
1211
|
// Only one-sided `"from"`/`"to"` equality shapes hit the planner's
|
|
702
1212
|
// deterministic top-N kill, so only they take the capped fallback.
|
|
703
|
-
const sideFiltered =
|
|
704
|
-
const fetchRows = async (
|
|
705
|
-
const
|
|
706
|
-
? [`"${side}" = '${address}'`, ...filters]
|
|
707
|
-
: filters;
|
|
708
|
-
const sideWhere = sideFilters.length > 0 ? `WHERE ${sideFilters.join(' AND ')}` : '';
|
|
1213
|
+
const sideFiltered = options.sender !== undefined || options.recipient !== undefined;
|
|
1214
|
+
const fetchRows = async () => {
|
|
1215
|
+
const where = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '';
|
|
709
1216
|
const select = `tx_hash, block_num, log_idx, "from", "to", ${tokenScoped ? 'token AS address, amount AS value' : 'address, value'}`;
|
|
710
1217
|
const table = tokenScoped ? 'token_transfers' : 'Transfer';
|
|
711
1218
|
try {
|
|
@@ -715,12 +1222,12 @@ export async function countScan(deps, options) {
|
|
|
715
1222
|
query: `
|
|
716
1223
|
SELECT ${select}
|
|
717
1224
|
FROM ${table}
|
|
718
|
-
${
|
|
1225
|
+
${where}
|
|
719
1226
|
ORDER BY block_num, log_idx
|
|
720
1227
|
LIMIT ${Schema.countCap}
|
|
721
1228
|
`,
|
|
722
1229
|
});
|
|
723
|
-
return { rows: result.rows, sample: false };
|
|
1230
|
+
return { capped: false, rows: result.rows, sample: false };
|
|
724
1231
|
}
|
|
725
1232
|
catch (error) {
|
|
726
1233
|
// Same planner kill as the page's side scan; the folded count is
|
|
@@ -733,16 +1240,14 @@ export async function countScan(deps, options) {
|
|
|
733
1240
|
query: `
|
|
734
1241
|
SELECT ${select}
|
|
735
1242
|
FROM ${table}
|
|
736
|
-
${
|
|
1243
|
+
${where}
|
|
737
1244
|
LIMIT ${Schema.countCap}
|
|
738
1245
|
`,
|
|
739
1246
|
});
|
|
740
|
-
return { rows: result.rows, sample: true };
|
|
1247
|
+
return { capped: result.rows.length >= Schema.countCap, rows: result.rows, sample: true };
|
|
741
1248
|
}
|
|
742
1249
|
};
|
|
743
|
-
const fetched =
|
|
744
|
-
? await Promise.all([fetchRows('from'), fetchRows('to')])
|
|
745
|
-
: [await fetchRows()];
|
|
1250
|
+
const fetched = [await fetchRows()];
|
|
746
1251
|
const { capped, rows } = collate(fetched, { limit: Schema.countCap, offset: 0, order: 'asc' });
|
|
747
1252
|
return foldCount(rows, { capped });
|
|
748
1253
|
}
|
|
@@ -786,16 +1291,23 @@ export async function countScan(deps, options) {
|
|
|
786
1291
|
* fee leg instead, keeping the reported lower bound from overcounting.
|
|
787
1292
|
*/
|
|
788
1293
|
export function foldCount(rows, options) {
|
|
789
|
-
const { capped } = options;
|
|
1294
|
+
const { capped, classificationRows = rows } = options;
|
|
1295
|
+
const rowKeys = new Set();
|
|
1296
|
+
for (const row of rows) {
|
|
1297
|
+
const transactionHash = Value.toText(row['tx_hash']);
|
|
1298
|
+
const logIndex = Value.toNumber(row['log_idx']);
|
|
1299
|
+
if (transactionHash && logIndex !== undefined)
|
|
1300
|
+
rowKeys.add(eventKey(transactionHash, logIndex));
|
|
1301
|
+
}
|
|
790
1302
|
let suppressed = 0;
|
|
791
|
-
for (const legs of legsByTransaction(rows)
|
|
1303
|
+
for (const [transactionHash, legs] of legsByTransaction(capped ? rows : classificationRows)) {
|
|
792
1304
|
if (capped) {
|
|
793
1305
|
suppressed += legs.filter(isBundleLeg).length;
|
|
794
1306
|
continue;
|
|
795
1307
|
}
|
|
796
1308
|
const bundle = classifyCrossToken(legs);
|
|
797
1309
|
if (bundle)
|
|
798
|
-
suppressed += bundle.suppressLogIndexes.length;
|
|
1310
|
+
suppressed += bundle.suppressLogIndexes.filter((logIndex) => rowKeys.has(eventKey(transactionHash, logIndex))).length;
|
|
799
1311
|
}
|
|
800
1312
|
return {
|
|
801
1313
|
totalCount: rows.length - suppressed,
|
|
@@ -876,13 +1388,14 @@ function isBundleLeg(leg) {
|
|
|
876
1388
|
* transfer and is left untouched.
|
|
877
1389
|
*/
|
|
878
1390
|
function classifyCrossToken(legs) {
|
|
879
|
-
const
|
|
880
|
-
const
|
|
1391
|
+
const ordered = [...legs].sort((a, b) => a.logIndex - b.logIndex);
|
|
1392
|
+
const toDex = ordered.find((leg) => Address.isEqual(leg.to, Addresses.stablecoinDex));
|
|
1393
|
+
const fromDex = ordered.find((leg) => Address.isEqual(leg.from, Addresses.stablecoinDex));
|
|
881
1394
|
if (!toDex || !fromDex)
|
|
882
1395
|
return undefined;
|
|
883
1396
|
const swapper = fromDex.to;
|
|
884
1397
|
const destTokenAddress = fromDex.address;
|
|
885
|
-
const delivery =
|
|
1398
|
+
const delivery = ordered.find((leg) => Address.isEqual(leg.address, destTokenAddress) &&
|
|
886
1399
|
Address.isEqual(leg.from, swapper) &&
|
|
887
1400
|
!Address.isEqual(leg.to, Addresses.stablecoinDex) &&
|
|
888
1401
|
!Address.isEqual(leg.to, swapper) &&
|
|
@@ -890,7 +1403,7 @@ function classifyCrossToken(legs) {
|
|
|
890
1403
|
// Require a token change so plain same-token DEX activity is not folded.
|
|
891
1404
|
if (!delivery || Address.isEqual(toDex.address, delivery.address))
|
|
892
1405
|
return undefined;
|
|
893
|
-
const suppressLogIndexes =
|
|
1406
|
+
const suppressLogIndexes = ordered
|
|
894
1407
|
.filter((leg) => leg.logIndex !== delivery.logIndex && isBundleLeg(leg))
|
|
895
1408
|
.map((leg) => leg.logIndex);
|
|
896
1409
|
return {
|