tapimo 0.9.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 +4887 -5074
- package/dist/App.d.ts.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 +168 -168
- 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 +5198 -5099
- 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 +270 -270
- 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 +52 -52
- package/dist/apps/data/routes/balances.d.ts +36 -36
- package/dist/apps/data/routes/balances.d.ts.map +1 -1
- package/dist/apps/data/routes/balances.js +135 -81
- package/dist/apps/data/routes/balances.js.map +1 -1
- package/dist/apps/data/routes/blocks.d.ts +125 -125
- package/dist/apps/data/routes/coingecko.d.ts +54 -54
- package/dist/apps/data/routes/earn.d.ts +512 -58
- package/dist/apps/data/routes/earn.d.ts.map +1 -1
- package/dist/apps/data/routes/earn.js +368 -44
- 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/rpc.d.ts +31 -31
- 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 +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 +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/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 +96 -96
- package/dist/apps/funding/routes/deposit-addresses.d.ts +52 -52
- package/dist/apps/funding/routes/quotes.d.ts +89 -89
- package/dist/apps/funding/routes/transfers.d.ts +4 -4
- 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/invitations.d.ts +134 -134
- 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/orgs.d.ts +98 -98
- 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/verified-token-requests.d.ts +83 -83
- 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 +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 +2 -2
- package/dist/db/Db.d.ts.map +1 -1
- package/dist/db/Db.js +17 -15
- package/dist/db/Db.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/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 +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 +3 -0
- package/dist/internal/funding/providers/relay.d.ts.map +1 -1
- package/dist/internal/funding/providers/relay.js +12 -1
- package/dist/internal/funding/providers/relay.js.map +1 -1
- package/package.json +3 -3
- package/src/Client.test-d.ts +60 -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 +83 -29
- package/src/apps/Zones.test.ts +5 -4
- package/src/apps/Zones.ts +9 -8
- 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 +119 -10
- package/src/apps/data/routes/balances.ts +154 -95
- package/src/apps/data/routes/blocks.test.ts +12 -6
- package/src/apps/data/routes/earn.test.ts +197 -0
- package/src/apps/data/routes/earn.ts +486 -44
- 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/routes/quotes.test.ts +1 -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 +33 -19
- package/src/db/tables/sponsoredTransactions.ts +9 -2
- package/src/db/tables/webhookSubscriptions.ts +116 -4
- 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 +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/relay.test.ts +1 -4
- package/src/internal/funding/providers/relay.ts +16 -1
|
@@ -7,17 +7,17 @@ import * as Auth from '../../../internal/Auth.js'
|
|
|
7
7
|
import * as Cache from '../../../internal/Cache.js'
|
|
8
8
|
import * as Cursor from '../../../internal/Cursor.js'
|
|
9
9
|
import * as Db from '../../../db/Db.js'
|
|
10
|
+
import type * as MetricSink from '../../../internal/MetricSink.js'
|
|
10
11
|
import * as OpenApi from '../../../internal/OpenApi.js'
|
|
11
12
|
import * as Response from '../../../internal/Response.js'
|
|
12
13
|
import * as Schema from '../../../internal/Schema.js'
|
|
13
14
|
import * as Store from '../../../internal/Store.js'
|
|
14
|
-
import type * as Tidx from '../../../internal/Tidx.js'
|
|
15
15
|
import * as Ttl from '../../../internal/Ttl.js'
|
|
16
16
|
import * as Value from '../../../internal/Value.js'
|
|
17
17
|
import type * as Viem from '../../../internal/Viem.js'
|
|
18
18
|
import * as WebhookDestination from '../../../internal/WebhookDestination.js'
|
|
19
|
+
import * as WebhookTransfer from '../../../internal/WebhookTransfer.js'
|
|
19
20
|
import * as Webhooks from '../../../internal/Webhooks.js'
|
|
20
|
-
import * as Receipts from './receipts.js'
|
|
21
21
|
import * as Transactions from './transactions.js'
|
|
22
22
|
import * as Transfers from './transfers.js'
|
|
23
23
|
|
|
@@ -67,7 +67,7 @@ const eventTypeDescriptions = [
|
|
|
67
67
|
},
|
|
68
68
|
{
|
|
69
69
|
description:
|
|
70
|
-
'A transaction included in a block, filterable on
|
|
70
|
+
'A transaction included in a block, filterable on transaction fields such as sender, recipient, value, calldata, inner account-abstraction calls, fees, and nonce.',
|
|
71
71
|
type: 'transaction:included',
|
|
72
72
|
},
|
|
73
73
|
] as const satisfies readonly { description: string; type: EventType }[]
|
|
@@ -233,13 +233,12 @@ export namespace schema {
|
|
|
233
233
|
|
|
234
234
|
/**
|
|
235
235
|
* `transaction:included` subscription filters: operators over every column the
|
|
236
|
-
* `txs` envelope exposes, plus inner account-abstraction `calls
|
|
237
|
-
* top-level `input` calldata
|
|
238
|
-
*
|
|
236
|
+
* `txs` envelope exposes, plus inner account-abstraction `calls` and the
|
|
237
|
+
* top-level `input` calldata. All optional and AND-combined; omit every field
|
|
238
|
+
* for the full stream.
|
|
239
239
|
*
|
|
240
240
|
* `address` matches either side (`from` OR `to`); when `includeCalls` is set
|
|
241
241
|
* (default), `to`/`address` also match an inner call target (`calls[].to`).
|
|
242
|
-
* Set `includeReceipt` to embed the receipt in the delivered payload.
|
|
243
242
|
*/
|
|
244
243
|
export const TxFilters = z
|
|
245
244
|
.strictObject({
|
|
@@ -269,16 +268,6 @@ export namespace schema {
|
|
|
269
268
|
z.meta({ examples: [true] }),
|
|
270
269
|
),
|
|
271
270
|
),
|
|
272
|
-
includeReceipt: z.optional(
|
|
273
|
-
z
|
|
274
|
-
.boolean()
|
|
275
|
-
.check(
|
|
276
|
-
z.describe(
|
|
277
|
-
'When true, embed the transaction receipt (execution `status`, `gasUsed`, `logs`, …) in the delivered payload under `meta.receipt`. Default false (the receipt is an extra fetch). Mirrors the read endpoint\u2019s `include=receipt`.',
|
|
278
|
-
),
|
|
279
|
-
z.meta({ examples: [true] }),
|
|
280
|
-
),
|
|
281
|
-
),
|
|
282
271
|
value: z.optional(compare.check(z.describe('Match the transaction value (wei) bounds.'))),
|
|
283
272
|
input: z.optional(
|
|
284
273
|
bytes.check(
|
|
@@ -305,9 +294,7 @@ export namespace schema {
|
|
|
305
294
|
feeToken: z.optional(
|
|
306
295
|
eq(Schema.TokenAddress).check(z.describe('Match the fee token address.')),
|
|
307
296
|
),
|
|
308
|
-
feePayer: z.optional(eq(Schema.Address).check(z.describe('Match the fee payer address.'))),
|
|
309
297
|
gasLimit: z.optional(compare.check(z.describe('Match the gas limit bounds.'))),
|
|
310
|
-
gasUsed: z.optional(compare.check(z.describe('Match the gas used bounds.'))),
|
|
311
298
|
maxFeePerGas: z.optional(compare.check(z.describe('Match the max fee per gas bounds.'))),
|
|
312
299
|
maxPriorityFeePerGas: z.optional(
|
|
313
300
|
compare.check(z.describe('Match the max priority fee per gas bounds.')),
|
|
@@ -322,14 +309,6 @@ export namespace schema {
|
|
|
322
309
|
timestamp: z.optional(
|
|
323
310
|
compare.check(z.describe('Match the block timestamp bounds (unix s).')),
|
|
324
311
|
),
|
|
325
|
-
status: z.optional(
|
|
326
|
-
z
|
|
327
|
-
.enum(['success', 'reverted'])
|
|
328
|
-
.check(
|
|
329
|
-
z.describe('Match the receipt execution status (routes through `receipts`).'),
|
|
330
|
-
z.meta({ examples: ['success'] }),
|
|
331
|
-
),
|
|
332
|
-
),
|
|
333
312
|
})
|
|
334
313
|
.check(
|
|
335
314
|
z.describe('Filters that narrow which included transactions trigger this webhook.'),
|
|
@@ -720,10 +699,21 @@ export namespace schema {
|
|
|
720
699
|
)
|
|
721
700
|
|
|
722
701
|
/**
|
|
723
|
-
* A `transaction` event payload
|
|
724
|
-
*
|
|
702
|
+
* A `transaction` event payload using the humanized transaction shape without
|
|
703
|
+
* opt-in read resources such as receipts.
|
|
725
704
|
*/
|
|
726
|
-
export const TransactionEvent =
|
|
705
|
+
export const TransactionEvent = z
|
|
706
|
+
.object({
|
|
707
|
+
...Transactions.schema.Transaction.shape,
|
|
708
|
+
meta: z
|
|
709
|
+
.object({
|
|
710
|
+
rpc: Transactions.schema.Rpc.Transaction.check(
|
|
711
|
+
z.describe('The original JSON-RPC transaction payload.'),
|
|
712
|
+
),
|
|
713
|
+
})
|
|
714
|
+
.check(z.describe('Metadata for this transaction webhook event.')),
|
|
715
|
+
})
|
|
716
|
+
.check(z.describe('A transaction included in a block.'))
|
|
727
717
|
|
|
728
718
|
/**
|
|
729
719
|
* A `log:emitted` event payload: the raw contract event log. When a
|
|
@@ -830,7 +820,7 @@ export namespace schema {
|
|
|
830
820
|
})
|
|
831
821
|
.check(z.describe('Payload for a `log:emitted` event (a raw contract event log).'))
|
|
832
822
|
|
|
833
|
-
/** Payload for a `block:created` event (
|
|
823
|
+
/** Payload for a `block:created` event (an RPC-derived block header). */
|
|
834
824
|
export const BlockEvent = z
|
|
835
825
|
.object({
|
|
836
826
|
number: z
|
|
@@ -1893,15 +1883,9 @@ const scanPageSize = 100
|
|
|
1893
1883
|
type ScanItem = { cursor: readonly [number, number]; data: unknown }
|
|
1894
1884
|
|
|
1895
1885
|
/**
|
|
1896
|
-
* The result of scanning one subscription page.
|
|
1897
|
-
*
|
|
1898
|
-
*
|
|
1899
|
-
* scanned. A batch-limited page omits it, so the poller resumes after the last
|
|
1900
|
-
* dispatched row instead of skipping later matches.
|
|
1901
|
-
* - **keyset** (the TIDX legacy scanners): rows come back `LIMIT`-bounded, so
|
|
1902
|
-
* the page only reports whether `hasMore` rows remain past it; the poller
|
|
1903
|
-
* advances only past the last dispatched row (jumping to the window end only
|
|
1904
|
-
* when the page was empty and complete).
|
|
1886
|
+
* The result of scanning one subscription page. `scannedThrough` is the
|
|
1887
|
+
* highest block fully scanned. A batch-limited page omits it, so the poller
|
|
1888
|
+
* resumes after the last dispatched row instead of skipping later matches.
|
|
1905
1889
|
*/
|
|
1906
1890
|
type ScanPage = {
|
|
1907
1891
|
hasMore?: boolean | undefined
|
|
@@ -1912,11 +1896,63 @@ type ScanPage = {
|
|
|
1912
1896
|
/** Per-subscription state retained between fair scan passes. */
|
|
1913
1897
|
type PollState = {
|
|
1914
1898
|
cursor: string
|
|
1899
|
+
filters: Record<string, unknown>
|
|
1915
1900
|
remaining: number
|
|
1916
1901
|
safeBlock: number
|
|
1917
1902
|
subscription: Webhooks.Subscription
|
|
1918
1903
|
}
|
|
1919
1904
|
|
|
1905
|
+
/** Concurrent per-subscription scans and cursor writes per poll round; caps RPC and DB fan-out. */
|
|
1906
|
+
const pollConcurrency = 8
|
|
1907
|
+
|
|
1908
|
+
/** Event types scanned together when their combined state fits one existing scan round. */
|
|
1909
|
+
const pollEventTypeConcurrency = 2
|
|
1910
|
+
|
|
1911
|
+
/** Maximum subscription states retained by concurrent event-type scan batches. */
|
|
1912
|
+
const pollStateLimit = WebhookTransfer.scanCohortSize * pollConcurrency
|
|
1913
|
+
|
|
1914
|
+
type Schedule = <value>(fn: () => Promise<value>) => Promise<value>
|
|
1915
|
+
|
|
1916
|
+
/** Schedules asynchronous work through one shared concurrency bound. */
|
|
1917
|
+
function createLimiter(limit: number): Schedule {
|
|
1918
|
+
let active = 0
|
|
1919
|
+
const waiting: (() => void)[] = []
|
|
1920
|
+
return async (fn) => {
|
|
1921
|
+
if (active >= limit) await new Promise<void>((resolve) => waiting.push(resolve))
|
|
1922
|
+
else active++
|
|
1923
|
+
try {
|
|
1924
|
+
return await fn()
|
|
1925
|
+
} finally {
|
|
1926
|
+
const next = waiting.shift()
|
|
1927
|
+
if (next) next()
|
|
1928
|
+
else active--
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
/** Runs `fn` over `items` with at most `limit` in flight, preserving order. */
|
|
1934
|
+
async function mapWithLimit<item, value>(
|
|
1935
|
+
items: readonly item[],
|
|
1936
|
+
limit: number,
|
|
1937
|
+
fn: (item: item) => Promise<value>,
|
|
1938
|
+
): Promise<PromiseSettledResult<value>[]> {
|
|
1939
|
+
const results: PromiseSettledResult<value>[] = []
|
|
1940
|
+
let next = 0
|
|
1941
|
+
await Promise.all(
|
|
1942
|
+
Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
1943
|
+
while (next < items.length) {
|
|
1944
|
+
const index = next++
|
|
1945
|
+
try {
|
|
1946
|
+
results[index] = { status: 'fulfilled', value: await fn(items[index]!) }
|
|
1947
|
+
} catch (reason) {
|
|
1948
|
+
results[index] = { reason, status: 'rejected' }
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
}),
|
|
1952
|
+
)
|
|
1953
|
+
return results
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1920
1956
|
/** Returns `candidate` only when it is lexicographically after `current`. */
|
|
1921
1957
|
function laterCursor(current: string, candidate: string): string {
|
|
1922
1958
|
const before = Cursor.decode(current, ['int', 'int'])
|
|
@@ -1935,13 +1971,9 @@ function laterCursor(current: string, candidate: string): string {
|
|
|
1935
1971
|
* matching row, advancing each active subscription's `(block, index)` keyset
|
|
1936
1972
|
* cursor.
|
|
1937
1973
|
*
|
|
1938
|
-
*
|
|
1939
|
-
* `
|
|
1940
|
-
*
|
|
1941
|
-
* `eth_getBlockReceipts`) — the same data sources the indexer itself consumes —
|
|
1942
|
-
* so they need no indexer and stay fresh. The pre-existing `token:transfer`
|
|
1943
|
-
* type continues to read its humanized shape from TIDX, clamped to the indexed
|
|
1944
|
-
* head so it never runs ahead of the indexer.
|
|
1974
|
+
* Every event type scans a **narrow confirmed window** `[cursor, head -
|
|
1975
|
+
* confirmations]` directly over JSON-RPC (`eth_getLogs`,
|
|
1976
|
+
* `eth_getBlockByNumber`) so webhook delivery does not wait for an indexer.
|
|
1945
1977
|
*
|
|
1946
1978
|
* The chain head is injectable (tests pin a fixed historical window); only
|
|
1947
1979
|
* blocks at least `confirmations` behind head are emitted, giving at-least-once,
|
|
@@ -1959,37 +1991,51 @@ export async function poll(options: poll.Options): Promise<poll.Result> {
|
|
|
1959
1991
|
cache: cache_metadata = Store.memory(),
|
|
1960
1992
|
chainId,
|
|
1961
1993
|
client,
|
|
1962
|
-
confirmations =
|
|
1994
|
+
confirmations = 1,
|
|
1963
1995
|
db,
|
|
1964
|
-
eventTypes = [
|
|
1996
|
+
eventTypes: eventTypes_input = [
|
|
1997
|
+
'token:transfer',
|
|
1998
|
+
'transaction:included',
|
|
1999
|
+
'log:emitted',
|
|
2000
|
+
'block:created',
|
|
2001
|
+
],
|
|
1965
2002
|
maxBlocks = 1_000,
|
|
1966
|
-
tidx,
|
|
1967
2003
|
} = options
|
|
1968
2004
|
const now = options.now ?? (() => new Date())
|
|
2005
|
+
options.signal?.throwIfAborted()
|
|
1969
2006
|
// Default (self-host) transport: deliver inline and record the outcome so a
|
|
1970
2007
|
// sustained-failure subscription auto-disables. On Cloudflare, callers pass a
|
|
1971
2008
|
// Queue producer instead and the consumer runs `deliverAndRecord`.
|
|
1972
2009
|
const dispatch =
|
|
1973
2010
|
options.dispatch ??
|
|
1974
2011
|
(({ envelope, subscription }) => Webhooks.deliverAndRecord(db, subscription, envelope))
|
|
2012
|
+
const dispatchBatch = options.dispatchBatch
|
|
1975
2013
|
|
|
1976
|
-
//
|
|
1977
|
-
//
|
|
1978
|
-
// indexer). A pinned `headBlockNumber` (tests) overrides both.
|
|
2014
|
+
// A pinned `headBlockNumber` lets a head subscription supply the exact live
|
|
2015
|
+
// boundary and keeps historical tests deterministic.
|
|
1979
2016
|
const override = options.headBlockNumber
|
|
1980
2017
|
let rpcHead: number | undefined = override === undefined ? undefined : Number(override)
|
|
1981
2018
|
const resolveRpcHead = async () => (rpcHead ??= await getRpcHead(client))
|
|
1982
|
-
|
|
1983
|
-
const
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
2019
|
+
|
|
2020
|
+
const headStartedAt = Date.now()
|
|
2021
|
+
const rpcSafeBlock = await (async () => {
|
|
2022
|
+
try {
|
|
2023
|
+
const safeBlock = Math.max(0, (await resolveRpcHead()) - confirmations)
|
|
2024
|
+
options.onStage?.({
|
|
2025
|
+
durationMs: Date.now() - headStartedAt,
|
|
2026
|
+
outcome: 'ok',
|
|
2027
|
+
stage: 'rpc_head',
|
|
2028
|
+
})
|
|
2029
|
+
return safeBlock
|
|
2030
|
+
} catch (error) {
|
|
2031
|
+
options.onStage?.({
|
|
2032
|
+
durationMs: Date.now() - headStartedAt,
|
|
2033
|
+
outcome: 'error',
|
|
2034
|
+
stage: 'rpc_head',
|
|
2035
|
+
})
|
|
2036
|
+
throw error
|
|
2037
|
+
}
|
|
2038
|
+
})()
|
|
1993
2039
|
const result: poll.Result = {
|
|
1994
2040
|
chainId,
|
|
1995
2041
|
dispatched: 0,
|
|
@@ -1998,134 +2044,337 @@ export async function poll(options: poll.Options): Promise<poll.Result> {
|
|
|
1998
2044
|
scanned: 0,
|
|
1999
2045
|
subscriptions: 0,
|
|
2000
2046
|
}
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2047
|
+
let cursorLagBlocks = 0
|
|
2048
|
+
const pollErrors: unknown[] = []
|
|
2049
|
+
|
|
2050
|
+
const eventTypes = [...new Set(eventTypes_input)]
|
|
2051
|
+
const subscriptionsStartedAt = Date.now()
|
|
2052
|
+
const pairs = await Webhooks.listActiveForChain(db, { chainId, eventTypes })
|
|
2053
|
+
options.signal?.throwIfAborted()
|
|
2054
|
+
options.onSubscriptions?.(pairs.length)
|
|
2055
|
+
options.onStage?.({
|
|
2056
|
+
durationMs: Date.now() - subscriptionsStartedAt,
|
|
2057
|
+
outcome: 'ok',
|
|
2058
|
+
stage: 'subscription_read',
|
|
2059
|
+
})
|
|
2060
|
+
const pairsByEventType = Map.groupBy(pairs, (pair) => pair.subscription.eventType)
|
|
2061
|
+
const eventTypes_active = eventTypes.filter(
|
|
2062
|
+
(eventType) => (pairsByEventType.get(eventType)?.length ?? 0) > 0,
|
|
2063
|
+
)
|
|
2064
|
+
const eventTypeConcurrency =
|
|
2065
|
+
pairs.length <= pollStateLimit
|
|
2066
|
+
? Math.min(pollEventTypeConcurrency, eventTypes_active.length)
|
|
2067
|
+
: Math.min(1, eventTypes_active.length)
|
|
2068
|
+
const scanRoundSize = Math.floor(pollStateLimit / Math.max(1, eventTypeConcurrency))
|
|
2069
|
+
const scheduleScan = createLimiter(pollConcurrency)
|
|
2070
|
+
|
|
2071
|
+
const eventTypeResults = await mapWithLimit(
|
|
2072
|
+
eventTypes_active,
|
|
2073
|
+
eventTypeConcurrency,
|
|
2074
|
+
async (eventType) => {
|
|
2075
|
+
options.signal?.throwIfAborted()
|
|
2076
|
+
const safeBlock = rpcSafeBlock
|
|
2077
|
+
const pairs = pairsByEventType.get(eventType) ?? []
|
|
2078
|
+
const durations = {
|
|
2079
|
+
cursor_write: 0,
|
|
2080
|
+
enrich: 0,
|
|
2081
|
+
scan: 0,
|
|
2082
|
+
} satisfies Record<Exclude<MetricSink.PollStage, 'rpc_head' | 'subscription_read'>, number>
|
|
2083
|
+
const observed = new Set<keyof typeof durations>()
|
|
2084
|
+
let cursorWriteOutcome: 'error' | 'ok' = 'ok'
|
|
2085
|
+
let scanOutcome: 'error' | 'ok' = 'ok'
|
|
2086
|
+
const writeCursor = async (parameters: Webhooks.compareAndSetCursor.Options) => {
|
|
2087
|
+
const startedAt = Date.now()
|
|
2088
|
+
observed.add('cursor_write')
|
|
2089
|
+
try {
|
|
2090
|
+
return await Webhooks.compareAndSetCursor(db, parameters)
|
|
2091
|
+
} catch (error) {
|
|
2092
|
+
cursorWriteOutcome = 'error'
|
|
2093
|
+
pollErrors.push(error)
|
|
2094
|
+
return false
|
|
2095
|
+
} finally {
|
|
2096
|
+
durations.cursor_write += Date.now() - startedAt
|
|
2097
|
+
}
|
|
2020
2098
|
}
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2099
|
+
let states: PollState[] = []
|
|
2100
|
+
for (const pair of pairs) {
|
|
2101
|
+
const { subscription } = pair
|
|
2102
|
+
result.subscriptions++
|
|
2103
|
+
let filters: Record<string, unknown>
|
|
2104
|
+
try {
|
|
2105
|
+
filters = parseFilters(subscription.eventType, subscription.filters)
|
|
2106
|
+
} catch (error) {
|
|
2107
|
+
if (!(error instanceof Webhooks.InvalidFilterError)) throw error
|
|
2108
|
+
options.signal?.throwIfAborted()
|
|
2109
|
+
// A breaking filter-schema removal must not poison every chain poll.
|
|
2110
|
+
// Disable the legacy subscription without widening its match set.
|
|
2111
|
+
await Webhooks.updateSubscription(db, subscription.owner, subscription.id, {
|
|
2112
|
+
status: 'disabled',
|
|
2113
|
+
})
|
|
2114
|
+
continue
|
|
2115
|
+
}
|
|
2116
|
+
let cursor = pair.cursor
|
|
2117
|
+
if (cursor === null) {
|
|
2118
|
+
// Legacy subscriptions without a creation checkpoint anchor at the
|
|
2119
|
+
// current confirmed head, avoiding a genesis backfill.
|
|
2120
|
+
await writeCursor({
|
|
2121
|
+
cursor: Cursor.encode([safeBlock, endOfBlockIndex]),
|
|
2122
|
+
expectedCursor: null,
|
|
2123
|
+
subscriptionId: subscription.id,
|
|
2124
|
+
})
|
|
2125
|
+
continue
|
|
2126
|
+
}
|
|
2127
|
+
const checkpoint = Cursor.decode(cursor, ['int'])
|
|
2128
|
+
if (checkpoint) {
|
|
2129
|
+
// Creation checkpoints record the live head without finalizing it. Rebase
|
|
2130
|
+
// to this poller's confirmed boundary so replacement blocks stay eligible.
|
|
2131
|
+
const confirmedCursor = Cursor.encode([
|
|
2132
|
+
Math.max(0, (checkpoint[0] as number) - confirmations),
|
|
2133
|
+
endOfBlockIndex,
|
|
2134
|
+
])
|
|
2135
|
+
const committed = await writeCursor({
|
|
2136
|
+
cursor: confirmedCursor,
|
|
2137
|
+
expectedCursor: cursor,
|
|
2138
|
+
subscriptionId: subscription.id,
|
|
2139
|
+
})
|
|
2140
|
+
if (!committed) continue
|
|
2141
|
+
cursor = confirmedCursor
|
|
2142
|
+
}
|
|
2143
|
+
const position = Cursor.decode(cursor, ['int', 'int'])
|
|
2144
|
+
if (position)
|
|
2145
|
+
cursorLagBlocks = Math.max(cursorLagBlocks, safeBlock - (position[0] as number))
|
|
2146
|
+
const window = scanWindow(cursor, safeBlock, maxBlocks)
|
|
2147
|
+
if (window && window.to < safeBlock) result.hasMore = true
|
|
2148
|
+
states.push({
|
|
2149
|
+
cursor,
|
|
2150
|
+
filters,
|
|
2151
|
+
remaining: batchSize,
|
|
2152
|
+
safeBlock: window?.to ?? safeBlock,
|
|
2153
|
+
subscription,
|
|
2033
2154
|
})
|
|
2034
|
-
if (!committed) continue
|
|
2035
|
-
cursor = confirmedCursor
|
|
2036
2155
|
}
|
|
2037
|
-
const window = legacy ? undefined : scanWindow(cursor, safeBlock, maxBlocks)
|
|
2038
|
-
states.push({
|
|
2039
|
-
cursor,
|
|
2040
|
-
remaining: batchSize,
|
|
2041
|
-
safeBlock: window?.to ?? safeBlock,
|
|
2042
|
-
subscription,
|
|
2043
|
-
})
|
|
2044
|
-
}
|
|
2045
2156
|
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
const
|
|
2052
|
-
let
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2157
|
+
// Every subscription gets one bounded page before any subscription gets a
|
|
2158
|
+
// continuation page, preserving fairness while allowing catch-up.
|
|
2159
|
+
while (states.length > 0) {
|
|
2160
|
+
options.signal?.throwIfAborted()
|
|
2161
|
+
const continuations: PollState[] = []
|
|
2162
|
+
const round = states
|
|
2163
|
+
for (let offset = 0; offset < round.length; offset += scanRoundSize) {
|
|
2164
|
+
const batch = round.slice(offset, offset + scanRoundSize)
|
|
2165
|
+
const windows = batch.flatMap((state) => {
|
|
2166
|
+
const window = scanWindow(state.cursor, state.safeBlock, maxBlocks)
|
|
2167
|
+
return window ? [window] : []
|
|
2168
|
+
})
|
|
2169
|
+
const maxWindowBlocks = Math.max(
|
|
2170
|
+
0,
|
|
2171
|
+
...windows.map((window) => window.to - window.from + 1),
|
|
2172
|
+
)
|
|
2173
|
+
const totalWindowBlocks = windows.reduce(
|
|
2174
|
+
(total, window) => total + window.to - window.from + 1,
|
|
2175
|
+
0,
|
|
2176
|
+
)
|
|
2177
|
+
options.onScanCheckpoint?.({
|
|
2178
|
+
concurrency: pollConcurrency,
|
|
2179
|
+
eventType,
|
|
2180
|
+
maxWindowBlocks,
|
|
2181
|
+
phase: 'start',
|
|
2182
|
+
rpcMethod: scanRpcMethod(eventType),
|
|
2183
|
+
subscriptionLimit: scanRoundSize,
|
|
2184
|
+
subscriptionCount: batch.length,
|
|
2185
|
+
totalWindowBlocks,
|
|
2186
|
+
})
|
|
2187
|
+
const scanStartedAt = Date.now()
|
|
2188
|
+
const pages = await scanSubscriptions({
|
|
2057
2189
|
client,
|
|
2058
|
-
|
|
2190
|
+
eventType,
|
|
2059
2191
|
maxBlocks,
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
tidx,
|
|
2192
|
+
schedule: scheduleScan,
|
|
2193
|
+
states: batch,
|
|
2063
2194
|
})
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
? await withFeeTokenMetadata(item.data, { cache: cache_metadata, chainId, client })
|
|
2081
|
-
: item.data
|
|
2082
|
-
const envelope = Webhooks.buildEnvelope({
|
|
2083
|
-
blockNumber: item.cursor[0],
|
|
2084
|
-
createdAt: now(),
|
|
2085
|
-
data,
|
|
2086
|
-
logIndex: item.cursor[1],
|
|
2087
|
-
subscription,
|
|
2195
|
+
options.onScanCheckpoint?.({
|
|
2196
|
+
concurrency: pollConcurrency,
|
|
2197
|
+
durationMs: Date.now() - scanStartedAt,
|
|
2198
|
+
eventType,
|
|
2199
|
+
failedSubscriptions: pages.filter((page) => page.status === 'rejected').length,
|
|
2200
|
+
matchedItems: pages.reduce(
|
|
2201
|
+
(total, page) => total + (page.status === 'fulfilled' ? page.value.items.length : 0),
|
|
2202
|
+
0,
|
|
2203
|
+
),
|
|
2204
|
+
maxWindowBlocks,
|
|
2205
|
+
outcome: scanResultOutcome(pages),
|
|
2206
|
+
phase: 'complete',
|
|
2207
|
+
rpcMethod: scanRpcMethod(eventType),
|
|
2208
|
+
subscriptionLimit: scanRoundSize,
|
|
2209
|
+
subscriptionCount: batch.length,
|
|
2210
|
+
totalWindowBlocks,
|
|
2088
2211
|
})
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2212
|
+
options.signal?.throwIfAborted()
|
|
2213
|
+
durations.scan += Date.now() - scanStartedAt
|
|
2214
|
+
observed.add('scan')
|
|
2215
|
+
if (pages.some((page) => page.status === 'rejected')) scanOutcome = 'error'
|
|
2216
|
+
|
|
2217
|
+
type Scanned = { page: ScanPage; state: PollState }
|
|
2218
|
+
const busy: Scanned[] = []
|
|
2219
|
+
const idle: Scanned[] = []
|
|
2220
|
+
for (let i = 0; i < batch.length; i++) {
|
|
2221
|
+
const state = batch[i]!
|
|
2222
|
+
const settled = pages[i]!
|
|
2223
|
+
if (settled.status === 'rejected') {
|
|
2224
|
+
pollErrors.push(settled.reason)
|
|
2225
|
+
continue
|
|
2226
|
+
}
|
|
2227
|
+
result.scanned += settled.value.items.length
|
|
2228
|
+
state.remaining -= settled.value.items.length
|
|
2229
|
+
;(settled.value.items.length > 0 ? busy : idle).push({ page: settled.value, state })
|
|
2094
2230
|
}
|
|
2095
|
-
accepted = item.cursor
|
|
2096
|
-
result.dispatched++
|
|
2097
|
-
}
|
|
2098
2231
|
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
if (progressed) {
|
|
2113
|
-
committed = await Webhooks.compareAndSetCursor(db, {
|
|
2114
|
-
cursor,
|
|
2115
|
-
expectedCursor: state.cursor,
|
|
2116
|
-
subscriptionId: subscription.id,
|
|
2232
|
+
// Empty pages only advance their cursor: no dispatch order to preserve,
|
|
2233
|
+
// so one statement commits the cohort while retaining per-row CAS semantics.
|
|
2234
|
+
const cursorWritesStartedAt = Date.now()
|
|
2235
|
+
const candidates = idle.flatMap(({ page, state }) => {
|
|
2236
|
+
options.signal?.throwIfAborted()
|
|
2237
|
+
const candidate =
|
|
2238
|
+
page.scannedThrough !== undefined
|
|
2239
|
+
? Cursor.encode([page.scannedThrough, endOfBlockIndex])
|
|
2240
|
+
: page.hasMore
|
|
2241
|
+
? undefined
|
|
2242
|
+
: Cursor.encode([state.safeBlock, endOfBlockIndex])
|
|
2243
|
+
const cursor = candidate ? laterCursor(state.cursor, candidate) : state.cursor
|
|
2244
|
+
return cursor === state.cursor ? [] : [{ cursor, state }]
|
|
2117
2245
|
})
|
|
2118
|
-
if (
|
|
2119
|
-
|
|
2246
|
+
if (candidates.length > 0) {
|
|
2247
|
+
const committed = await (async () => {
|
|
2248
|
+
try {
|
|
2249
|
+
return await Webhooks.compareAndSetCursors(db, {
|
|
2250
|
+
cursors: candidates.map(({ cursor, state }) => ({
|
|
2251
|
+
cursor,
|
|
2252
|
+
expectedCursor: state.cursor,
|
|
2253
|
+
subscriptionId: state.subscription.id,
|
|
2254
|
+
})),
|
|
2255
|
+
})
|
|
2256
|
+
} catch (error) {
|
|
2257
|
+
cursorWriteOutcome = 'error'
|
|
2258
|
+
pollErrors.push(error)
|
|
2259
|
+
return new Set<string>()
|
|
2260
|
+
}
|
|
2261
|
+
})()
|
|
2262
|
+
for (const { cursor, state } of candidates)
|
|
2263
|
+
if (committed.has(state.subscription.id)) state.cursor = cursor
|
|
2264
|
+
durations.cursor_write += Date.now() - cursorWritesStartedAt
|
|
2265
|
+
observed.add('cursor_write')
|
|
2266
|
+
}
|
|
2120
2267
|
|
|
2121
|
-
|
|
2122
|
-
|
|
2268
|
+
for (const { page, state } of busy) {
|
|
2269
|
+
options.signal?.throwIfAborted()
|
|
2270
|
+
const { subscription } = state
|
|
2271
|
+
|
|
2272
|
+
let accepted: readonly [number, number] | undefined
|
|
2273
|
+
let failed = false
|
|
2274
|
+
const buildDispatchable = async (item: ScanItem): Promise<poll.Dispatchable> => {
|
|
2275
|
+
options.signal?.throwIfAborted()
|
|
2276
|
+
// Token metadata is best-effort for webhooks: transfers use it for display,
|
|
2277
|
+
// while transactions expose fee-token references without blocking delivery.
|
|
2278
|
+
const enrichStartedAt = Date.now()
|
|
2279
|
+
const data =
|
|
2280
|
+
eventType === 'token:transfer'
|
|
2281
|
+
? await withTokenMetadata(item.data, { cache: cache_metadata, chainId, client })
|
|
2282
|
+
: eventType === 'transaction:included'
|
|
2283
|
+
? await withFeeTokenMetadata(item.data, {
|
|
2284
|
+
cache: cache_metadata,
|
|
2285
|
+
chainId,
|
|
2286
|
+
client,
|
|
2287
|
+
})
|
|
2288
|
+
: item.data
|
|
2289
|
+
durations.enrich += Date.now() - enrichStartedAt
|
|
2290
|
+
observed.add('enrich')
|
|
2291
|
+
options.signal?.throwIfAborted()
|
|
2292
|
+
const envelope = Webhooks.buildEnvelope({
|
|
2293
|
+
blockNumber: item.cursor[0],
|
|
2294
|
+
createdAt: now(),
|
|
2295
|
+
data,
|
|
2296
|
+
logIndex: item.cursor[1],
|
|
2297
|
+
subscription,
|
|
2298
|
+
})
|
|
2299
|
+
return { envelope, subscription }
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
if (dispatchBatch) {
|
|
2303
|
+
const dispatchables: poll.Dispatchable[] = []
|
|
2304
|
+
for (const item of page.items) dispatchables.push(await buildDispatchable(item))
|
|
2305
|
+
options.signal?.throwIfAborted()
|
|
2306
|
+
const delivered = await dispatchBatch(dispatchables)
|
|
2307
|
+
if (delivered.ok) {
|
|
2308
|
+
accepted = page.items.at(-1)?.cursor
|
|
2309
|
+
result.dispatched += dispatchables.length
|
|
2310
|
+
} else {
|
|
2311
|
+
result.failed += dispatchables.length
|
|
2312
|
+
failed = true
|
|
2313
|
+
}
|
|
2314
|
+
} else {
|
|
2315
|
+
for (const item of page.items) {
|
|
2316
|
+
const dispatchable = await buildDispatchable(item)
|
|
2317
|
+
const delivered = await dispatch(dispatchable)
|
|
2318
|
+
if (!delivered.ok) {
|
|
2319
|
+
result.failed++
|
|
2320
|
+
failed = true
|
|
2321
|
+
break
|
|
2322
|
+
}
|
|
2323
|
+
accepted = item.cursor
|
|
2324
|
+
result.dispatched++
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
// Persist every accepted page before another subscription begins its
|
|
2329
|
+
// continuation, so a later scan failure retains completed work.
|
|
2330
|
+
const candidate = (() => {
|
|
2331
|
+
if (failed) return accepted ? Cursor.encode([...accepted]) : undefined
|
|
2332
|
+
if (page.scannedThrough !== undefined)
|
|
2333
|
+
return Cursor.encode([page.scannedThrough, endOfBlockIndex])
|
|
2334
|
+
if (accepted) return Cursor.encode([...accepted])
|
|
2335
|
+
if (!page.hasMore) return Cursor.encode([state.safeBlock, endOfBlockIndex])
|
|
2336
|
+
return undefined
|
|
2337
|
+
})()
|
|
2338
|
+
const cursor = candidate ? laterCursor(state.cursor, candidate) : state.cursor
|
|
2339
|
+
const progressed = cursor !== state.cursor
|
|
2340
|
+
let committed = false
|
|
2341
|
+
if (progressed) {
|
|
2342
|
+
committed = await writeCursor({
|
|
2343
|
+
cursor,
|
|
2344
|
+
expectedCursor: state.cursor,
|
|
2345
|
+
subscriptionId: subscription.id,
|
|
2346
|
+
})
|
|
2347
|
+
if (committed) state.cursor = cursor
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
if (!failed && page.hasMore === true) {
|
|
2351
|
+
if (committed && state.remaining > 0) continuations.push(state)
|
|
2352
|
+
else result.hasMore = true
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
states = continuations
|
|
2123
2357
|
}
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2358
|
+
for (const stage of observed) {
|
|
2359
|
+
const outcome = (() => {
|
|
2360
|
+
if (stage === 'cursor_write') return cursorWriteOutcome
|
|
2361
|
+
if (stage === 'scan') return scanOutcome
|
|
2362
|
+
return 'ok'
|
|
2363
|
+
})()
|
|
2364
|
+
options.onStage?.({
|
|
2365
|
+
durationMs: durations[stage],
|
|
2366
|
+
eventType,
|
|
2367
|
+
outcome,
|
|
2368
|
+
stage,
|
|
2369
|
+
})
|
|
2370
|
+
}
|
|
2371
|
+
},
|
|
2372
|
+
)
|
|
2373
|
+
for (const result of eventTypeResults)
|
|
2374
|
+
if (result.status === 'rejected') pollErrors.push(result.reason)
|
|
2127
2375
|
|
|
2128
|
-
|
|
2376
|
+
options.onCursorLag?.(cursorLagBlocks)
|
|
2377
|
+
if (pollErrors.length > 0) throw pollErrors[0]
|
|
2129
2378
|
return result
|
|
2130
2379
|
}
|
|
2131
2380
|
|
|
@@ -2145,9 +2394,9 @@ export declare namespace poll {
|
|
|
2145
2394
|
cache?: Store.Store | undefined
|
|
2146
2395
|
/** Chain to poll. Callers loop over chains. */
|
|
2147
2396
|
chainId: number
|
|
2148
|
-
/** viem RPC client for the chain
|
|
2397
|
+
/** viem RPC client for the chain. */
|
|
2149
2398
|
client: Viem.getClient.ReturnType
|
|
2150
|
-
/** Reorg-safety window: only emit blocks this far behind head (default
|
|
2399
|
+
/** Reorg-safety window: only emit blocks this far behind head (default 1). */
|
|
2151
2400
|
confirmations?: number | undefined
|
|
2152
2401
|
/** Database holding subscriptions + cursors. */
|
|
2153
2402
|
db: Db.Db
|
|
@@ -2156,16 +2405,26 @@ export declare namespace poll {
|
|
|
2156
2405
|
* on Cloudflare pass a Queue producer so retry/backoff/DLQ are native.
|
|
2157
2406
|
*/
|
|
2158
2407
|
dispatch?: ((input: Dispatchable) => Promise<Webhooks.Result>) | undefined
|
|
2408
|
+
/** Batch delivery transport. A successful result confirms every row in the input. */
|
|
2409
|
+
dispatchBatch?: ((input: readonly Dispatchable[]) => Promise<Webhooks.Result>) | undefined
|
|
2159
2410
|
/** Event types to poll (default all). */
|
|
2160
2411
|
eventTypes?: readonly EventType[] | undefined
|
|
2161
2412
|
/** Current chain head; the poller emits up to `head - confirmations`. */
|
|
2162
2413
|
headBlockNumber?: bigint | number | undefined
|
|
2163
2414
|
/** Backfill cap: most blocks an RPC scanner inspects per tick (default 1000). */
|
|
2164
2415
|
maxBlocks?: number | undefined
|
|
2416
|
+
/** Records the worst confirmed-head distance without changing the poll result. */
|
|
2417
|
+
onCursorLag?: ((blocks: number) => void) | undefined
|
|
2418
|
+
/** Records bounded scan inputs before allocation and outcomes after completion. */
|
|
2419
|
+
onScanCheckpoint?: ((checkpoint: ScanCheckpoint) => void) | undefined
|
|
2420
|
+
/** Records bounded, per-tick poll-stage timings without subscription identities. */
|
|
2421
|
+
onStage?: ((stage: Stage) => void) | undefined
|
|
2422
|
+
/** Records subscriptions eligible for this poll before any scan runs. */
|
|
2423
|
+
onSubscriptions?: ((count: number) => void) | undefined
|
|
2165
2424
|
/** Clock for envelope timestamps; injectable for tests. */
|
|
2166
2425
|
now?: (() => Date) | undefined
|
|
2167
|
-
/**
|
|
2168
|
-
|
|
2426
|
+
/** Stops scanning and dispatch when the caller loses its execution lease. */
|
|
2427
|
+
signal?: AbortSignal | undefined
|
|
2169
2428
|
}
|
|
2170
2429
|
/** Summary counters for one poll tick. */
|
|
2171
2430
|
type Result = {
|
|
@@ -2175,6 +2434,8 @@ export declare namespace poll {
|
|
|
2175
2434
|
dispatched: number
|
|
2176
2435
|
/** Rows whose dispatch failed (pausing that subscription). */
|
|
2177
2436
|
failed: number
|
|
2437
|
+
/** True when a bounded scan left committed work behind the same head. */
|
|
2438
|
+
hasMore?: boolean | undefined
|
|
2178
2439
|
/** Highest confirmed block emitted this tick (`head - confirmations`). */
|
|
2179
2440
|
safeBlock: number
|
|
2180
2441
|
/** Total rows scanned across all subscriptions. */
|
|
@@ -2182,6 +2443,44 @@ export declare namespace poll {
|
|
|
2182
2443
|
/** Active subscriptions visited. */
|
|
2183
2444
|
subscriptions: number
|
|
2184
2445
|
}
|
|
2446
|
+
/** One scan boundary used to correlate production memory failures with workload shape. */
|
|
2447
|
+
type ScanCheckpoint = {
|
|
2448
|
+
/** Shared upper bound for concurrent upstream scans across event types. */
|
|
2449
|
+
concurrency: number
|
|
2450
|
+
/** Scan duration, present only after completion. */
|
|
2451
|
+
durationMs?: number | undefined
|
|
2452
|
+
/** Subscription event type scanned by this batch. */
|
|
2453
|
+
eventType: EventType
|
|
2454
|
+
/** Subscriptions whose scan rejected, present only after completion. */
|
|
2455
|
+
failedSubscriptions?: number | undefined
|
|
2456
|
+
/** Matching rows retained by completed pages, present only after completion. */
|
|
2457
|
+
matchedItems?: number | undefined
|
|
2458
|
+
/** Largest block window requested by one subscription. */
|
|
2459
|
+
maxWindowBlocks: number
|
|
2460
|
+
/** Whether the checkpoint precedes allocation or follows scan completion. */
|
|
2461
|
+
phase: 'complete' | 'start'
|
|
2462
|
+
/** Outcome of the completed RPC-backed scan. */
|
|
2463
|
+
outcome?: 'empty' | 'error' | 'missing' | 'ok' | undefined
|
|
2464
|
+
/** JSON-RPC method used by this event type's scanner. */
|
|
2465
|
+
rpcMethod: 'eth_getBlockByNumber' | 'eth_getLogs'
|
|
2466
|
+
/** Subscriptions retained in this scan batch. */
|
|
2467
|
+
subscriptionCount: number
|
|
2468
|
+
/** Maximum subscriptions retained by this event-type batch. */
|
|
2469
|
+
subscriptionLimit: number
|
|
2470
|
+
/** Sum of subscription block windows in this scan batch. */
|
|
2471
|
+
totalWindowBlocks: number
|
|
2472
|
+
}
|
|
2473
|
+
/** One bounded poll-stage timing observation. */
|
|
2474
|
+
type Stage = {
|
|
2475
|
+
/** Stage duration in milliseconds. */
|
|
2476
|
+
durationMs: number
|
|
2477
|
+
/** Event type being processed, omitted for the shared RPC-head stage. */
|
|
2478
|
+
eventType?: EventType | undefined
|
|
2479
|
+
/** Whether the stage completed. */
|
|
2480
|
+
outcome: 'error' | 'ok'
|
|
2481
|
+
/** Bounded stage name. */
|
|
2482
|
+
stage: MetricSink.PollStage
|
|
2483
|
+
}
|
|
2185
2484
|
}
|
|
2186
2485
|
|
|
2187
2486
|
/**
|
|
@@ -2270,7 +2569,7 @@ function withResolvedFeeToken(data: Record<string, unknown>, metadata: Map<strin
|
|
|
2270
2569
|
return result
|
|
2271
2570
|
}
|
|
2272
2571
|
|
|
2273
|
-
/** Adds best-effort fee-token
|
|
2572
|
+
/** Adds a best-effort fee-token reference to a transaction. */
|
|
2274
2573
|
async function withFeeTokenMetadata(
|
|
2275
2574
|
data: unknown,
|
|
2276
2575
|
deps: {
|
|
@@ -2281,37 +2580,12 @@ async function withFeeTokenMetadata(
|
|
|
2281
2580
|
): Promise<unknown> {
|
|
2282
2581
|
if (typeof data !== 'object' || data === null) return data
|
|
2283
2582
|
const transaction = data as Record<string, unknown>
|
|
2284
|
-
const
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
const transactionAddress = feeTokenAddress(transaction['feeToken'])
|
|
2291
|
-
if (transactionAddress) addresses.add(transactionAddress.toLowerCase())
|
|
2292
|
-
if (typeof receipt === 'object' && receipt !== null) {
|
|
2293
|
-
const receiptAddress = feeTokenAddress((receipt as Record<string, unknown>)['feeToken'])
|
|
2294
|
-
if (receiptAddress) addresses.add(receiptAddress.toLowerCase())
|
|
2295
|
-
}
|
|
2296
|
-
if (addresses.size === 0) return data
|
|
2297
|
-
|
|
2298
|
-
const resolved = await Promise.all(
|
|
2299
|
-
[...addresses].map(
|
|
2300
|
-
async (address) => [address, await resolveTokenMetadata(address, deps)] as const,
|
|
2301
|
-
),
|
|
2302
|
-
)
|
|
2303
|
-
const metadata = new Map(
|
|
2304
|
-
resolved.filter((entry): entry is readonly [string, TokenMetadata] => entry[1] !== undefined),
|
|
2305
|
-
)
|
|
2306
|
-
const result = withResolvedFeeToken(transaction, metadata)
|
|
2307
|
-
if (typeof receipt !== 'object' || receipt === null) return result
|
|
2308
|
-
return {
|
|
2309
|
-
...result,
|
|
2310
|
-
meta: {
|
|
2311
|
-
...(meta as Record<string, unknown>),
|
|
2312
|
-
receipt: withResolvedFeeToken(receipt as Record<string, unknown>, metadata),
|
|
2313
|
-
},
|
|
2314
|
-
}
|
|
2583
|
+
const address = feeTokenAddress(transaction['feeToken'])
|
|
2584
|
+
if (!address) return data
|
|
2585
|
+
const token = await resolveTokenMetadata(address, deps)
|
|
2586
|
+
const metadata = new Map<string, TokenMetadata>()
|
|
2587
|
+
if (token) metadata.set(address.toLowerCase(), token)
|
|
2588
|
+
return withResolvedFeeToken(transaction, metadata)
|
|
2315
2589
|
}
|
|
2316
2590
|
|
|
2317
2591
|
/** Returns the live RPC head block number for the chain. */
|
|
@@ -2319,81 +2593,74 @@ export async function getRpcHead(client: Viem.getClient.ReturnType): Promise<num
|
|
|
2319
2593
|
return Number(await client.getBlockNumber())
|
|
2320
2594
|
}
|
|
2321
2595
|
|
|
2322
|
-
/**
|
|
2323
|
-
|
|
2324
|
-
* The legacy `token:transfer` type works back from this so it never scans past
|
|
2325
|
-
* the indexer.
|
|
2326
|
-
*/
|
|
2327
|
-
export async function getIndexedHead(tidx: Tidx.Client, chainId: number): Promise<number> {
|
|
2328
|
-
const result = await tidx.fetch({
|
|
2329
|
-
chainId,
|
|
2330
|
-
query: 'SELECT num FROM blocks ORDER BY num DESC LIMIT 1' as string,
|
|
2331
|
-
})
|
|
2332
|
-
return Value.toNumber(result.rows[0]?.['num']) ?? 0
|
|
2333
|
-
}
|
|
2334
|
-
|
|
2335
|
-
/**
|
|
2336
|
-
* Scans new events for one subscription. The RPC monitoring types
|
|
2337
|
-
* (`log:emitted` / `block:created` / `transaction:included`) read a confirmed
|
|
2338
|
-
* window directly over JSON-RPC; the legacy `token:transfer` type dispatches to
|
|
2339
|
-
* its TIDX read core (`Transfers.scan`).
|
|
2340
|
-
*/
|
|
2341
|
-
async function scanSubscription(options: {
|
|
2342
|
-
batchSize: number
|
|
2343
|
-
cache: ChainCache
|
|
2596
|
+
/** Groups aligned cursors only when their bounded upstream scans are equivalent. */
|
|
2597
|
+
async function scanSubscriptions(options: {
|
|
2344
2598
|
client: Viem.getClient.ReturnType
|
|
2345
|
-
|
|
2599
|
+
eventType: EventType
|
|
2346
2600
|
maxBlocks: number
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
}
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2601
|
+
schedule: Schedule
|
|
2602
|
+
states: readonly PollState[]
|
|
2603
|
+
}): Promise<PromiseSettledResult<ScanPage>[]> {
|
|
2604
|
+
const { client, eventType, maxBlocks, schedule, states } = options
|
|
2605
|
+
if (eventType !== 'token:transfer')
|
|
2606
|
+
return scanIndependent({ client, eventType, maxBlocks, schedule, states })
|
|
2607
|
+
return WebhookTransfer.scan({
|
|
2608
|
+
client,
|
|
2609
|
+
concurrency: pollConcurrency,
|
|
2610
|
+
maxBlocks,
|
|
2611
|
+
schedule,
|
|
2612
|
+
states: states.map((state) => ({
|
|
2613
|
+
filters: state.filters,
|
|
2614
|
+
limit: Math.min(scanPageSize, state.remaining),
|
|
2615
|
+
window: scanWindow(state.cursor, state.safeBlock, maxBlocks),
|
|
2616
|
+
})),
|
|
2617
|
+
})
|
|
2618
|
+
}
|
|
2365
2619
|
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
maxBlocks,
|
|
2373
|
-
safeBlock,
|
|
2374
|
-
})
|
|
2620
|
+
/** Returns the bounded JSON-RPC method used by an event scanner. */
|
|
2621
|
+
function scanRpcMethod(eventType: EventType): poll.ScanCheckpoint['rpcMethod'] {
|
|
2622
|
+
return eventType === 'block:created' || eventType === 'transaction:included'
|
|
2623
|
+
? 'eth_getBlockByNumber'
|
|
2624
|
+
: 'eth_getLogs'
|
|
2625
|
+
}
|
|
2375
2626
|
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2627
|
+
/** Classifies a completed batch without exposing an unbounded upstream error. */
|
|
2628
|
+
function scanResultOutcome(
|
|
2629
|
+
pages: readonly PromiseSettledResult<ScanPage>[],
|
|
2630
|
+
): NonNullable<poll.ScanCheckpoint['outcome']> {
|
|
2631
|
+
const rejected = pages.filter((page) => page.status === 'rejected')
|
|
2632
|
+
if (rejected.some((page) => page.reason instanceof BlockUnavailableError)) return 'missing'
|
|
2633
|
+
if (rejected.length > 0) return 'error'
|
|
2634
|
+
return pages.every((page) => page.status === 'fulfilled' && page.value.items.length === 0)
|
|
2635
|
+
? 'empty'
|
|
2636
|
+
: 'ok'
|
|
2637
|
+
}
|
|
2385
2638
|
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2639
|
+
/** Scans CPU-matched streams per subscription while sharing concurrent block reads. */
|
|
2640
|
+
function scanIndependent(options: {
|
|
2641
|
+
client: Viem.getClient.ReturnType
|
|
2642
|
+
eventType: Exclude<EventType, 'token:transfer'>
|
|
2643
|
+
maxBlocks: number
|
|
2644
|
+
schedule: Schedule
|
|
2645
|
+
states: readonly PollState[]
|
|
2646
|
+
}): Promise<PromiseSettledResult<ScanPage>[]> {
|
|
2647
|
+
const { client, eventType, maxBlocks, schedule, states } = options
|
|
2648
|
+
const cache = createChainCache(client)
|
|
2649
|
+
return mapWithLimit(states, pollConcurrency, (state) =>
|
|
2650
|
+
schedule(() => {
|
|
2651
|
+
const shared = {
|
|
2652
|
+
batchSize: Math.min(scanPageSize, state.remaining),
|
|
2653
|
+
cache,
|
|
2654
|
+
cursor: state.cursor,
|
|
2655
|
+
filters: state.filters,
|
|
2656
|
+
maxBlocks,
|
|
2657
|
+
safeBlock: state.safeBlock,
|
|
2658
|
+
}
|
|
2659
|
+
if (eventType === 'block:created') return scanBlocks(shared)
|
|
2660
|
+
if (eventType === 'log:emitted') return scanLogs({ ...shared, client })
|
|
2661
|
+
return scanTxs(shared)
|
|
2662
|
+
}),
|
|
2392
2663
|
)
|
|
2393
|
-
// A cap-truncated fallback sample is an arbitrary subset: dispatching it
|
|
2394
|
-
// would advance the cursor past unseen matches. Hold position and retry.
|
|
2395
|
-
if (page.capped) return { hasMore: true, items: [] }
|
|
2396
|
-
return { hasMore: page.hasMore, items: page.items }
|
|
2397
2664
|
}
|
|
2398
2665
|
|
|
2399
2666
|
const filterSchemas = {
|
|
@@ -2422,18 +2689,6 @@ export function parseFilters(
|
|
|
2422
2689
|
return result.data as Record<string, unknown>
|
|
2423
2690
|
}
|
|
2424
2691
|
|
|
2425
|
-
/** Narrows stored `transfer` filters into validated `Transfers.scan` predicates. */
|
|
2426
|
-
function transferFilters(filters: Record<string, unknown>) {
|
|
2427
|
-
const parsed = schema.TransferFilters.safeParse(filters)
|
|
2428
|
-
const { address, recipient, sender, token } = parsed.success ? parsed.data : {}
|
|
2429
|
-
return {
|
|
2430
|
-
...(address === undefined ? {} : { eitherSide: address }),
|
|
2431
|
-
...(recipient === undefined ? {} : { recipient }),
|
|
2432
|
-
...(sender === undefined ? {} : { sender }),
|
|
2433
|
-
...(token === undefined ? {} : { token }),
|
|
2434
|
-
}
|
|
2435
|
-
}
|
|
2436
|
-
|
|
2437
2692
|
/** Recursively lowercases string leaves of a filter operator (for address args). */
|
|
2438
2693
|
function lowercaseLeaves(op: unknown): unknown {
|
|
2439
2694
|
if (typeof op === 'string') return op.toLowerCase()
|
|
@@ -2573,62 +2828,49 @@ function atOrBefore(block: number, index: number, skip: readonly [number, number
|
|
|
2573
2828
|
/** A viem-formatted block header (no transactions), as returned by `getBlock`. */
|
|
2574
2829
|
type RpcBlock = Awaited<ReturnType<Viem.getClient.ReturnType['getBlock']>>
|
|
2575
2830
|
|
|
2576
|
-
/** Per-
|
|
2831
|
+
/** Per-batch single-flight cache for chain RPC reads. */
|
|
2577
2832
|
type ChainCache = {
|
|
2578
2833
|
/** Full raw block (with transactions) via `eth_getBlockByNumber(n, true)`. */
|
|
2579
2834
|
block(n: number): Promise<Record<string, unknown>>
|
|
2580
2835
|
/** Viem-formatted block header (transaction hashes only) via `getBlock`. */
|
|
2581
2836
|
header(n: number): Promise<RpcBlock | undefined>
|
|
2582
|
-
/** Receipts for a block keyed by lowercased transaction hash. */
|
|
2583
|
-
receipts(n: number): Promise<Map<string, Record<string, unknown>>>
|
|
2584
2837
|
}
|
|
2585
2838
|
|
|
2586
|
-
/** Builds a per-
|
|
2839
|
+
/** Builds a per-batch {@link ChainCache} bound to a viem client. */
|
|
2587
2840
|
function createChainCache(client: Viem.getClient.ReturnType): ChainCache {
|
|
2588
2841
|
const headers = new Map<number, Promise<RpcBlock | undefined>>()
|
|
2589
2842
|
const blocks = new Map<number, Promise<Record<string, unknown>>>()
|
|
2590
|
-
|
|
2843
|
+
|
|
2844
|
+
const singleFlight = <key, value>(
|
|
2845
|
+
pending: Map<key, Promise<value>>,
|
|
2846
|
+
key: key,
|
|
2847
|
+
load: () => Promise<value>,
|
|
2848
|
+
) => {
|
|
2849
|
+
const existing = pending.get(key)
|
|
2850
|
+
if (existing) return existing
|
|
2851
|
+
const promise = load().finally(() => pending.delete(key))
|
|
2852
|
+
pending.set(key, promise)
|
|
2853
|
+
return promise
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2591
2856
|
return {
|
|
2592
2857
|
block(n) {
|
|
2593
|
-
|
|
2594
|
-
if (!p) {
|
|
2858
|
+
return singleFlight(blocks, n, () =>
|
|
2595
2859
|
// The transaction payload reuses `humanizeTransaction`, which consumes
|
|
2596
2860
|
// the raw RPC tx shape, so the full block stays on the untyped path.
|
|
2597
|
-
|
|
2861
|
+
rpc<Record<string, unknown> | null>(client, 'eth_getBlockByNumber', [
|
|
2598
2862
|
blockTag(n),
|
|
2599
2863
|
true,
|
|
2600
2864
|
]).then((block) => {
|
|
2601
2865
|
if (!block) throw new BlockUnavailableError(n)
|
|
2602
2866
|
return block
|
|
2603
|
-
})
|
|
2604
|
-
|
|
2605
|
-
}
|
|
2606
|
-
return p
|
|
2867
|
+
}),
|
|
2868
|
+
)
|
|
2607
2869
|
},
|
|
2608
2870
|
header(n) {
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
headers.set(n, p)
|
|
2613
|
-
}
|
|
2614
|
-
return p
|
|
2615
|
-
},
|
|
2616
|
-
receipts(n) {
|
|
2617
|
-
let p = receipts.get(n)
|
|
2618
|
-
if (!p) {
|
|
2619
|
-
p = rpc<Record<string, unknown>[]>(client, 'eth_getBlockReceipts', [blockTag(n)]).then(
|
|
2620
|
-
(list) => {
|
|
2621
|
-
const map = new Map<string, Record<string, unknown>>()
|
|
2622
|
-
for (const receipt of list ?? []) {
|
|
2623
|
-
const hash = receipt['transactionHash']
|
|
2624
|
-
if (typeof hash === 'string') map.set(hash.toLowerCase(), receipt)
|
|
2625
|
-
}
|
|
2626
|
-
return map
|
|
2627
|
-
},
|
|
2628
|
-
)
|
|
2629
|
-
receipts.set(n, p)
|
|
2630
|
-
}
|
|
2631
|
-
return p
|
|
2871
|
+
return singleFlight(headers, n, () =>
|
|
2872
|
+
client.getBlock({ blockNumber: BigInt(n), includeTransactions: false }),
|
|
2873
|
+
)
|
|
2632
2874
|
},
|
|
2633
2875
|
}
|
|
2634
2876
|
}
|
|
@@ -2788,7 +3030,7 @@ function pushTopics(f: z.output<typeof schema.LogFilters>, event: AbiEvent | und
|
|
|
2788
3030
|
pushHex(f.topic2),
|
|
2789
3031
|
pushHex(f.topic3),
|
|
2790
3032
|
]
|
|
2791
|
-
while (topics.at(-1) === undefined) topics.pop()
|
|
3033
|
+
while (topics.length > 0 && topics.at(-1) === undefined) topics.pop()
|
|
2792
3034
|
return topics.map((topic) => topic ?? null)
|
|
2793
3035
|
}
|
|
2794
3036
|
|
|
@@ -2824,17 +3066,7 @@ function decodeScanLog(log: ScanLog, event: AbiEvent): ScanLog {
|
|
|
2824
3066
|
}
|
|
2825
3067
|
}
|
|
2826
3068
|
|
|
2827
|
-
/**
|
|
2828
|
-
* Scans contract event logs over the confirmed window via `eth_getLogs`,
|
|
2829
|
-
* then re-checks **every** filter in JS. Operators the RPC cannot express
|
|
2830
|
-
* (`not`, ranges, non-indexed `args`) still apply. Two modes:
|
|
2831
|
-
* - **raw** (no `signature`): `topic0..3` are matched directly.
|
|
2832
|
-
* - **decoded** (`signature` present): the selector, raw topics, and supported
|
|
2833
|
-
* indexed `args` are pushed down before the JS pass matches decoded values.
|
|
2834
|
-
*
|
|
2835
|
-
* Exact and membership address/topic operators are pushed down to the node;
|
|
2836
|
-
* all operators are re-checked in JS over the narrow window.
|
|
2837
|
-
*/
|
|
3069
|
+
/** Scans contract logs for one subscription, then re-checks every filter in memory. */
|
|
2838
3070
|
async function scanLogs(options: {
|
|
2839
3071
|
batchSize: number
|
|
2840
3072
|
cache: ChainCache
|
|
@@ -2856,8 +3088,6 @@ async function scanLogs(options: {
|
|
|
2856
3088
|
])
|
|
2857
3089
|
|
|
2858
3090
|
const address = pushAddress(f.address)
|
|
2859
|
-
const fromBlock = BigInt(window.from)
|
|
2860
|
-
const toBlock = BigInt(window.to)
|
|
2861
3091
|
const topics = pushTopics(f, event)
|
|
2862
3092
|
const hasRawTopics = topics.slice(1).some((topic) => topic !== null)
|
|
2863
3093
|
const args = event ? pushEventArgs(f.args, event) : {}
|
|
@@ -2878,16 +3108,13 @@ async function scanLogs(options: {
|
|
|
2878
3108
|
...(address ? { address } : {}),
|
|
2879
3109
|
...(Object.keys(args).length > 0 ? { args: args as never } : {}),
|
|
2880
3110
|
event,
|
|
2881
|
-
fromBlock,
|
|
3111
|
+
fromBlock: BigInt(window.from),
|
|
2882
3112
|
strict: false,
|
|
2883
|
-
toBlock,
|
|
3113
|
+
toBlock: BigInt(window.to),
|
|
2884
3114
|
})) as unknown as ScanLog[]
|
|
2885
|
-
|
|
2886
3115
|
logs.sort((a, b) => {
|
|
2887
|
-
const
|
|
2888
|
-
|
|
2889
|
-
if (ab !== bb) return ab - bb
|
|
2890
|
-
return (a.logIndex ?? 0) - (b.logIndex ?? 0)
|
|
3116
|
+
const block = Number(a.blockNumber ?? 0n) - Number(b.blockNumber ?? 0n)
|
|
3117
|
+
return block || (a.logIndex ?? 0) - (b.logIndex ?? 0)
|
|
2891
3118
|
})
|
|
2892
3119
|
|
|
2893
3120
|
const items: ScanItem[] = []
|
|
@@ -2897,9 +3124,7 @@ async function scanLogs(options: {
|
|
|
2897
3124
|
if (blockNumber === undefined || logIndex === undefined) continue
|
|
2898
3125
|
if (atOrBefore(blockNumber, logIndex, window.skip)) continue
|
|
2899
3126
|
if (!matchesLog(log, f, event)) continue
|
|
2900
|
-
const
|
|
2901
|
-
const timestamp = blockIso(header)
|
|
2902
|
-
const data = toLogEvent(log, timestamp, f.signature, event)
|
|
3127
|
+
const data = toLogEvent(log, blockIso(await cache.header(blockNumber)), f.signature, event)
|
|
2903
3128
|
if (data) {
|
|
2904
3129
|
items.push({ cursor: [data.blockNumber, data.logIndex], data })
|
|
2905
3130
|
if (items.length >= batchSize) return { hasMore: true, items }
|
|
@@ -2915,6 +3140,10 @@ function matchesLog(
|
|
|
2915
3140
|
event: AbiEvent | undefined,
|
|
2916
3141
|
): boolean {
|
|
2917
3142
|
const topics = log.topics
|
|
3143
|
+
if (event) {
|
|
3144
|
+
const selector = encodeEventTopics({ abi: [event] })[0]
|
|
3145
|
+
if (topics[0]?.toLowerCase() !== selector.toLowerCase()) return false
|
|
3146
|
+
}
|
|
2918
3147
|
if (f.address !== undefined && !matchesOperator(log.address, f.address)) return false
|
|
2919
3148
|
if (
|
|
2920
3149
|
f.blockNumber !== undefined &&
|
|
@@ -2998,11 +3227,7 @@ function toBlockEvent(block: RpcBlock): BlockEventData | undefined {
|
|
|
2998
3227
|
}
|
|
2999
3228
|
}
|
|
3000
3229
|
|
|
3001
|
-
/**
|
|
3002
|
-
* Scans new blocks over the confirmed window via viem's `getBlock`. One row per
|
|
3003
|
-
* block (the keyset index is always 0); filters match block-level metadata. An
|
|
3004
|
-
* empty filter is a per-block heartbeat.
|
|
3005
|
-
*/
|
|
3230
|
+
/** Scans new blocks over one subscription's confirmed window. */
|
|
3006
3231
|
async function scanBlocks(options: {
|
|
3007
3232
|
batchSize: number
|
|
3008
3233
|
cache: ChainCache
|
|
@@ -3017,9 +3242,9 @@ async function scanBlocks(options: {
|
|
|
3017
3242
|
if (!window) return { items: [], scannedThrough: undefined }
|
|
3018
3243
|
|
|
3019
3244
|
const items: ScanItem[] = []
|
|
3020
|
-
for (let
|
|
3021
|
-
if (atOrBefore(
|
|
3022
|
-
const block = await cache.header(
|
|
3245
|
+
for (let blockNumber = window.from; blockNumber <= window.to; blockNumber++) {
|
|
3246
|
+
if (atOrBefore(blockNumber, 0, window.skip)) continue
|
|
3247
|
+
const block = await cache.header(blockNumber)
|
|
3023
3248
|
if (!block) continue
|
|
3024
3249
|
if (!matchesBlock(block, f)) continue
|
|
3025
3250
|
const data = toBlockEvent(block)
|
|
@@ -3053,7 +3278,7 @@ function matchesBlock(block: RpcBlock, f: z.output<typeof schema.BlockFilters>):
|
|
|
3053
3278
|
return true
|
|
3054
3279
|
}
|
|
3055
3280
|
|
|
3056
|
-
// transaction:included (eth_getBlockByNumber
|
|
3281
|
+
// transaction:included (eth_getBlockByNumber)
|
|
3057
3282
|
|
|
3058
3283
|
/** Maps a `transaction:included` scalar filter field to its raw-tx field + kind. */
|
|
3059
3284
|
const txFields = {
|
|
@@ -3071,13 +3296,7 @@ const txFields = {
|
|
|
3071
3296
|
value: { field: 'value', kind: 'number' },
|
|
3072
3297
|
} as const satisfies Record<string, { field: string; kind: 'number' | 'string' }>
|
|
3073
3298
|
|
|
3074
|
-
/**
|
|
3075
|
-
* Scans included transactions over the confirmed window. Each block is fetched
|
|
3076
|
-
* with full transactions via `eth_getBlockByNumber(n, true)`; receipts are
|
|
3077
|
-
* fetched lazily (only when a `status`/`gasUsed`/`feePayer` filter needs them).
|
|
3078
|
-
* The delivered payload starts from `Transactions.humanizeTransaction`; the
|
|
3079
|
-
* poller then resolves its fee-token references.
|
|
3080
|
-
*/
|
|
3299
|
+
/** Scans included transactions over one subscription's confirmed window. */
|
|
3081
3300
|
async function scanTxs(options: {
|
|
3082
3301
|
batchSize: number
|
|
3083
3302
|
cache: ChainCache
|
|
@@ -3092,55 +3311,33 @@ async function scanTxs(options: {
|
|
|
3092
3311
|
if (!window) return { items: [], scannedThrough: undefined }
|
|
3093
3312
|
|
|
3094
3313
|
const includeCalls = f.includeCalls ?? true
|
|
3095
|
-
const includeReceipt = f.includeReceipt ?? false
|
|
3096
|
-
// Filter-driven receipt need gates matching; `includeReceipt` only enriches the
|
|
3097
|
-
// payload, so it never drops a row when the receipt is missing.
|
|
3098
|
-
const needsReceipt = f.status !== undefined || f.gasUsed !== undefined || f.feePayer !== undefined
|
|
3099
|
-
|
|
3100
3314
|
const items: ScanItem[] = []
|
|
3101
|
-
for (let
|
|
3102
|
-
const block = await cache.block(
|
|
3103
|
-
const
|
|
3104
|
-
|
|
3105
|
-
|
|
3315
|
+
for (let blockNumber = window.from; blockNumber <= window.to; blockNumber++) {
|
|
3316
|
+
const block = await cache.block(blockNumber)
|
|
3317
|
+
const transactions = Array.isArray(block['transactions'])
|
|
3318
|
+
? (block['transactions'] as unknown[])
|
|
3319
|
+
: []
|
|
3106
3320
|
const timestamp = asString(block['timestamp'])
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
const
|
|
3110
|
-
|
|
3111
|
-
if (
|
|
3112
|
-
const
|
|
3113
|
-
if (needsReceipt && !receipt) continue
|
|
3114
|
-
if (!matchesTx(tx, f, includeCalls, receipt, timestamp, n)) continue
|
|
3115
|
-
const parsed = Transactions.schema.Rpc.Transaction.safeParse(tx)
|
|
3321
|
+
for (let offset = 0; offset < transactions.length; offset++) {
|
|
3322
|
+
const transaction = transactions[offset] as Record<string, unknown>
|
|
3323
|
+
const transactionIndex = Value.hexToNumber(transaction['transactionIndex']) ?? offset
|
|
3324
|
+
if (atOrBefore(blockNumber, transactionIndex, window.skip)) continue
|
|
3325
|
+
if (!matchesTx(transaction, f, includeCalls, timestamp, blockNumber)) continue
|
|
3326
|
+
const parsed = Transactions.schema.Rpc.Transaction.safeParse(transaction)
|
|
3116
3327
|
if (!parsed.success) continue
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
// endpoint's `include=receipt` shape so `data` stays a valid envelope.
|
|
3120
|
-
if (includeReceipt && receipt) {
|
|
3121
|
-
const r = Receipts.schema.Rpc.Receipt.safeParse(receipt)
|
|
3122
|
-
if (r.success)
|
|
3123
|
-
data = {
|
|
3124
|
-
...data,
|
|
3125
|
-
meta: {
|
|
3126
|
-
...(data['meta'] as Record<string, unknown>),
|
|
3127
|
-
receipt: Receipts.humanizeReceipt(r.data),
|
|
3128
|
-
},
|
|
3129
|
-
}
|
|
3130
|
-
}
|
|
3131
|
-
items.push({ cursor: [n, index], data })
|
|
3328
|
+
const data = Transactions.humanizeTransaction(parsed.data)
|
|
3329
|
+
items.push({ cursor: [blockNumber, transactionIndex], data })
|
|
3132
3330
|
if (items.length >= batchSize) return { hasMore: true, items }
|
|
3133
3331
|
}
|
|
3134
3332
|
}
|
|
3135
3333
|
return { items, scannedThrough: window.to }
|
|
3136
3334
|
}
|
|
3137
3335
|
|
|
3138
|
-
/** Re-checks
|
|
3336
|
+
/** Re-checks transaction filters against the raw RPC transaction. */
|
|
3139
3337
|
function matchesTx(
|
|
3140
3338
|
tx: Record<string, unknown>,
|
|
3141
3339
|
f: z.output<typeof schema.TxFilters>,
|
|
3142
3340
|
includeCalls: boolean,
|
|
3143
|
-
receipt: Record<string, unknown> | undefined,
|
|
3144
3341
|
timestamp: string | undefined,
|
|
3145
3342
|
blockNumber: number,
|
|
3146
3343
|
): boolean {
|
|
@@ -3176,19 +3373,6 @@ function matchesTx(
|
|
|
3176
3373
|
|
|
3177
3374
|
if (f.timestamp !== undefined && !matchesOperator(timestamp, f.timestamp, 'number')) return false
|
|
3178
3375
|
|
|
3179
|
-
if (receipt) {
|
|
3180
|
-
if (f.feePayer !== undefined && !matchesOperator(asString(receipt['feePayer']), f.feePayer))
|
|
3181
|
-
return false
|
|
3182
|
-
if (
|
|
3183
|
-
f.gasUsed !== undefined &&
|
|
3184
|
-
!matchesOperator(asString(receipt['gasUsed']), f.gasUsed, 'number')
|
|
3185
|
-
)
|
|
3186
|
-
return false
|
|
3187
|
-
if (f.status !== undefined) {
|
|
3188
|
-
const ok = Value.hexToNumber(receipt['status']) === 1
|
|
3189
|
-
if ((f.status === 'success') !== ok) return false
|
|
3190
|
-
}
|
|
3191
|
-
}
|
|
3192
3376
|
return true
|
|
3193
3377
|
}
|
|
3194
3378
|
|