tapimo 0.9.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ApiKey.d.ts +2 -1
- package/dist/ApiKey.d.ts.map +1 -1
- package/dist/ApiKeys.d.ts +4 -2
- package/dist/ApiKeys.d.ts.map +1 -1
- package/dist/ApiKeys.js +26 -1
- package/dist/ApiKeys.js.map +1 -1
- package/dist/App.d.ts +4292 -3708
- package/dist/App.d.ts.map +1 -1
- package/dist/App.js +1 -0
- package/dist/App.js.map +1 -1
- package/dist/Webhooks.d.ts +21 -20
- package/dist/Webhooks.d.ts.map +1 -1
- package/dist/Webhooks.js +66 -23
- package/dist/Webhooks.js.map +1 -1
- package/dist/admin/App.d.ts +91 -84
- package/dist/admin/App.d.ts.map +1 -1
- package/dist/admin/apps/api-keys.d.ts +26 -17
- package/dist/admin/apps/api-keys.d.ts.map +1 -1
- package/dist/admin/apps/api-keys.js +6 -0
- package/dist/admin/apps/api-keys.js.map +1 -1
- package/dist/admin/apps/organizations.d.ts +4 -2
- package/dist/admin/apps/organizations.d.ts.map +1 -1
- package/dist/admin/apps/verified-tokens.d.ts +168 -168
- package/dist/admin/ui.generated.d.ts +1 -1
- package/dist/admin/ui.generated.js +69 -69
- package/dist/admin/ui.generated.js.map +1 -1
- package/dist/analytics/Analytics.d.ts.map +1 -1
- package/dist/analytics/Analytics.js +26 -0
- package/dist/analytics/Analytics.js.map +1 -1
- package/dist/apps/Zones.d.ts +4280 -84
- package/dist/apps/Zones.d.ts.map +1 -1
- package/dist/apps/Zones.js +30 -10
- package/dist/apps/Zones.js.map +1 -1
- package/dist/apps/data/App.d.ts +5799 -4929
- package/dist/apps/data/App.d.ts.map +1 -1
- package/dist/apps/data/FxOracle.d.ts +4 -5
- package/dist/apps/data/FxOracle.d.ts.map +1 -1
- package/dist/apps/data/FxOracle.js +19 -4
- package/dist/apps/data/FxOracle.js.map +1 -1
- package/dist/apps/data/routes/activities.d.ts +3391 -492
- package/dist/apps/data/routes/activities.d.ts.map +1 -1
- package/dist/apps/data/routes/activities.js +372 -29
- package/dist/apps/data/routes/activities.js.map +1 -1
- package/dist/apps/data/routes/addresses.d.ts +853 -339
- package/dist/apps/data/routes/addresses.d.ts.map +1 -1
- package/dist/apps/data/routes/balances.d.ts +10 -10
- package/dist/apps/data/routes/balances.d.ts.map +1 -1
- package/dist/apps/data/routes/balances.js +145 -83
- package/dist/apps/data/routes/balances.js.map +1 -1
- package/dist/apps/data/routes/blocks.d.ts +123 -123
- package/dist/apps/data/routes/coingecko.d.ts +169 -169
- package/dist/apps/data/routes/earn.d.ts +455 -1
- package/dist/apps/data/routes/earn.d.ts.map +1 -1
- package/dist/apps/data/routes/earn.js +369 -45
- package/dist/apps/data/routes/earn.js.map +1 -1
- package/dist/apps/data/routes/exchanges.d.ts.map +1 -1
- package/dist/apps/data/routes/exchanges.js +114 -118
- package/dist/apps/data/routes/exchanges.js.map +1 -1
- package/dist/apps/data/routes/fee-amm.d.ts +24 -24
- package/dist/apps/data/routes/indexer.d.ts +35 -34
- package/dist/apps/data/routes/indexer.d.ts.map +1 -1
- package/dist/apps/data/routes/indexer.js +3 -2
- package/dist/apps/data/routes/indexer.js.map +1 -1
- package/dist/apps/data/routes/receipts.d.ts.map +1 -1
- package/dist/apps/data/routes/receipts.js +68 -47
- package/dist/apps/data/routes/receipts.js.map +1 -1
- package/dist/apps/data/routes/rpc.d.ts +31 -31
- package/dist/apps/data/routes/rpc.d.ts.map +1 -1
- package/dist/apps/data/routes/rpc.js +5 -1
- package/dist/apps/data/routes/rpc.js.map +1 -1
- package/dist/apps/data/routes/tokenlist.d.ts +12 -12
- package/dist/apps/data/routes/tokens.d.ts +461 -459
- package/dist/apps/data/routes/tokens.d.ts.map +1 -1
- package/dist/apps/data/routes/transactions.d.ts +313 -305
- package/dist/apps/data/routes/transactions.d.ts.map +1 -1
- package/dist/apps/data/routes/transactions.js +25 -23
- package/dist/apps/data/routes/transactions.js.map +1 -1
- package/dist/apps/data/routes/transfers.d.ts +108 -86
- package/dist/apps/data/routes/transfers.d.ts.map +1 -1
- package/dist/apps/data/routes/transfers.js +618 -105
- package/dist/apps/data/routes/transfers.js.map +1 -1
- package/dist/apps/data/routes/valuation.d.ts +21 -21
- package/dist/apps/data/routes/valuation.d.ts.map +1 -1
- package/dist/apps/data/routes/valuation.js +2 -2
- package/dist/apps/data/routes/valuation.js.map +1 -1
- package/dist/apps/data/routes/webhooks.d.ts +519 -868
- package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
- package/dist/apps/data/routes/webhooks.js +642 -353
- package/dist/apps/data/routes/webhooks.js.map +1 -1
- package/dist/apps/data/routes/zones.d.ts +31 -31
- package/dist/apps/data/routes/zones.d.ts.map +1 -1
- package/dist/apps/data/routes/zones.js +4 -2
- package/dist/apps/data/routes/zones.js.map +1 -1
- package/dist/apps/funding/routes/deposit-addresses.js +1 -1
- package/dist/apps/funding/routes/deposit-addresses.js.map +1 -1
- package/dist/apps/funding/routes/providers.d.ts +13 -13
- package/dist/apps/funding/routes/quotes.d.ts +89 -89
- package/dist/apps/funding/routes/transfers.d.ts +36 -36
- package/dist/apps/funding/routes/transfers.js +1 -1
- package/dist/apps/funding/routes/transfers.js.map +1 -1
- package/dist/apps/management/App.d.ts +11 -309
- package/dist/apps/management/App.d.ts.map +1 -1
- package/dist/apps/management/routes/api-keys.d.ts +74 -74
- package/dist/apps/management/routes/billing.js +1 -1
- package/dist/apps/management/routes/billing.js.map +1 -1
- package/dist/apps/management/routes/faucet.d.ts +35 -35
- package/dist/apps/management/routes/faucet.js +2 -2
- package/dist/apps/management/routes/faucet.js.map +1 -1
- package/dist/apps/management/routes/invitations.d.ts +134 -134
- package/dist/apps/management/routes/invite-links.d.ts +80 -80
- package/dist/apps/management/routes/members.d.ts +69 -69
- package/dist/apps/management/routes/orgs.d.ts +98 -98
- package/dist/apps/management/routes/projects.d.ts +60 -60
- package/dist/apps/management/routes/usage.d.ts +72 -72
- package/dist/apps/management/routes/verified-token-requests.d.ts +83 -83
- package/dist/apps/management/routes/webhooks.d.ts +203 -618
- package/dist/apps/management/routes/webhooks.d.ts.map +1 -1
- package/dist/apps/mcp.d.ts.map +1 -1
- package/dist/apps/mcp.js +4 -1
- package/dist/apps/mcp.js.map +1 -1
- package/dist/apps/mpp/App.d.ts +56 -44
- package/dist/apps/mpp/App.d.ts.map +1 -1
- package/dist/apps/mpp/Mpp.d.ts +3 -0
- package/dist/apps/mpp/Mpp.d.ts.map +1 -1
- package/dist/apps/mpp/Mpp.js +3 -0
- package/dist/apps/mpp/Mpp.js.map +1 -1
- package/dist/apps/mpp/internal/Adapter.d.ts.map +1 -1
- package/dist/apps/mpp/internal/Adapter.js +9 -2
- package/dist/apps/mpp/internal/Adapter.js.map +1 -1
- package/dist/db/Db.d.ts +4 -2
- package/dist/db/Db.d.ts.map +1 -1
- package/dist/db/Db.js +75 -15
- package/dist/db/Db.js.map +1 -1
- package/dist/db/Schema.d.ts +19 -0
- package/dist/db/Schema.d.ts.map +1 -1
- package/dist/db/Schema.js +24 -0
- package/dist/db/Schema.js.map +1 -1
- package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
- package/dist/db/tables/sponsoredTransactions.js +4 -1
- package/dist/db/tables/sponsoredTransactions.js.map +1 -1
- package/dist/db/tables/webhookQueueEvents.d.ts +122 -0
- package/dist/db/tables/webhookQueueEvents.d.ts.map +1 -0
- package/dist/db/tables/webhookQueueEvents.js +160 -0
- package/dist/db/tables/webhookQueueEvents.js.map +1 -0
- package/dist/db/tables/webhookSubscriptions.d.ts +58 -3
- package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -1
- package/dist/db/tables/webhookSubscriptions.js +86 -3
- package/dist/db/tables/webhookSubscriptions.js.map +1 -1
- package/dist/internal/Auth.d.ts +3 -0
- package/dist/internal/Auth.d.ts.map +1 -1
- package/dist/internal/Auth.js +6 -4
- package/dist/internal/Auth.js.map +1 -1
- package/dist/internal/MetricSink.d.ts +139 -3
- package/dist/internal/MetricSink.d.ts.map +1 -1
- package/dist/internal/MetricSink.js +144 -4
- package/dist/internal/MetricSink.js.map +1 -1
- package/dist/internal/RateLimit.d.ts +4 -6
- package/dist/internal/RateLimit.d.ts.map +1 -1
- package/dist/internal/RateLimit.js +15 -14
- package/dist/internal/RateLimit.js.map +1 -1
- package/dist/internal/Schema.js +1 -1
- package/dist/internal/Schema.js.map +1 -1
- package/dist/internal/Store.d.ts +2 -1
- package/dist/internal/Store.d.ts.map +1 -1
- package/dist/internal/Store.js +57 -18
- package/dist/internal/Store.js.map +1 -1
- package/dist/internal/Tidx.d.ts +1 -3
- package/dist/internal/Tidx.d.ts.map +1 -1
- package/dist/internal/Tidx.js +1 -3
- package/dist/internal/Tidx.js.map +1 -1
- package/dist/internal/Value.d.ts +2 -0
- package/dist/internal/Value.d.ts.map +1 -1
- package/dist/internal/Value.js +25 -0
- package/dist/internal/Value.js.map +1 -1
- package/dist/internal/Viem.d.ts.map +1 -1
- package/dist/internal/Viem.js +2 -1
- package/dist/internal/Viem.js.map +1 -1
- package/dist/internal/WebhookDestination.d.ts +2 -0
- package/dist/internal/WebhookDestination.d.ts.map +1 -1
- package/dist/internal/WebhookDestination.js +11 -0
- package/dist/internal/WebhookDestination.js.map +1 -1
- package/dist/internal/WebhookTransfer.d.ts +75 -0
- package/dist/internal/WebhookTransfer.d.ts.map +1 -0
- package/dist/internal/WebhookTransfer.js +299 -0
- package/dist/internal/WebhookTransfer.js.map +1 -0
- package/dist/internal/Webhooks.d.ts +176 -0
- package/dist/internal/Webhooks.d.ts.map +1 -1
- package/dist/internal/Webhooks.js +216 -20
- package/dist/internal/Webhooks.js.map +1 -1
- package/dist/internal/funding/Provider.d.ts +4 -0
- package/dist/internal/funding/Provider.d.ts.map +1 -1
- package/dist/internal/funding/Provider.js +11 -7
- package/dist/internal/funding/Provider.js.map +1 -1
- package/dist/internal/funding/Reconciliation.d.ts.map +1 -1
- package/dist/internal/funding/Reconciliation.js +128 -0
- package/dist/internal/funding/Reconciliation.js.map +1 -1
- package/dist/internal/funding/providers/relay.d.ts +6 -4
- package/dist/internal/funding/providers/relay.d.ts.map +1 -1
- package/dist/internal/funding/providers/relay.js +71 -9
- package/dist/internal/funding/providers/relay.js.map +1 -1
- package/package.json +4 -4
- package/src/ApiKeys.test.ts +20 -0
- package/src/ApiKeys.ts +28 -1
- package/src/App.test.ts +10 -8
- package/src/App.ts +1 -0
- package/src/Client.test-d.ts +61 -5
- package/src/Webhooks.bench.ts +714 -0
- package/src/Webhooks.memory.bench.ts +250 -0
- package/src/Webhooks.test.ts +77 -6
- package/src/Webhooks.ts +85 -29
- package/src/admin/apps/api-keys.test.ts +9 -0
- package/src/admin/apps/api-keys.ts +15 -0
- package/src/admin/ui/src/lib/query-client.ts +3 -2
- package/src/admin/ui/src/routes/api-keys.tsx +31 -9
- package/src/admin/ui.generated.ts +69 -69
- package/src/analytics/Analytics.test.ts +8 -0
- package/src/analytics/Analytics.ts +26 -0
- package/src/apps/Zones.test.ts +23 -5
- package/src/apps/Zones.ts +31 -10
- package/src/apps/composition.test.ts +1 -0
- package/src/apps/data/App.test.ts +2 -2
- package/src/apps/data/FxOracle.test.ts +18 -0
- package/src/apps/data/FxOracle.ts +28 -5
- package/src/apps/data/routes/activities.bench.ts +41 -0
- package/src/apps/data/routes/activities.test.ts +122 -2
- package/src/apps/data/routes/activities.ts +435 -32
- package/src/apps/data/routes/balances.bench.ts +21 -0
- package/src/apps/data/routes/balances.test.ts +119 -10
- package/src/apps/data/routes/balances.ts +164 -97
- package/src/apps/data/routes/blocks.bench.ts +34 -0
- package/src/apps/data/routes/blocks.test.ts +12 -6
- package/src/apps/data/routes/coingecko.bench.ts +53 -0
- package/src/apps/data/routes/earn.bench.ts +71 -0
- package/src/apps/data/routes/earn.test.ts +197 -0
- package/src/apps/data/routes/earn.ts +487 -45
- package/src/apps/data/routes/exchanges.bench.ts +85 -0
- package/src/apps/data/routes/exchanges.test.ts +7 -6
- package/src/apps/data/routes/exchanges.ts +124 -117
- package/src/apps/data/routes/fee-amm.bench.ts +13 -0
- package/src/apps/data/routes/indexer.bench.ts +8 -0
- package/src/apps/data/routes/indexer.test.ts +2 -1
- package/src/apps/data/routes/indexer.ts +3 -2
- package/src/apps/data/routes/receipts.bench.ts +39 -0
- package/src/apps/data/routes/receipts.test.ts +24 -7
- package/src/apps/data/routes/receipts.ts +67 -50
- package/src/apps/data/routes/rpc.bench.ts +14 -0
- package/src/apps/data/routes/rpc.test.ts +27 -0
- package/src/apps/data/routes/rpc.ts +5 -1
- package/src/apps/data/routes/tokenlist.bench.ts +3 -0
- package/src/apps/data/routes/tokens.bench.ts +76 -0
- package/src/apps/data/routes/tokens.test.ts +51 -0
- package/src/apps/data/routes/transactions.bench.ts +40 -0
- package/src/apps/data/routes/transactions.test.ts +42 -0
- package/src/apps/data/routes/transactions.ts +44 -39
- package/src/apps/data/routes/transfers.bench.ts +28 -0
- package/src/apps/data/routes/transfers.test.ts +402 -24
- package/src/apps/data/routes/transfers.ts +708 -101
- package/src/apps/data/routes/valuation.bench.ts +14 -0
- package/src/apps/data/routes/valuation.test.ts +26 -0
- package/src/apps/data/routes/valuation.ts +6 -2
- package/src/apps/data/routes/verified-tokens.bench.ts +8 -0
- package/src/apps/data/routes/webhooks.bench.ts +26 -0
- package/src/apps/data/routes/webhooks.scan.test.ts +326 -0
- package/src/apps/data/routes/webhooks.test.ts +466 -135
- package/src/apps/data/routes/webhooks.ts +820 -429
- package/src/apps/data/routes/zones.bench.ts +32 -0
- package/src/apps/data/routes/zones.test.ts +1 -1
- package/src/apps/data/routes/zones.ts +7 -2
- package/src/apps/funding/routes/chains.test.ts +1 -1
- package/src/apps/funding/routes/deposit-addresses.test.ts +1 -1
- package/src/apps/funding/routes/deposit-addresses.ts +1 -1
- package/src/apps/funding/routes/providers.test.ts +1 -1
- package/src/apps/funding/routes/quotes.test.ts +2 -2
- package/src/apps/funding/routes/transfers.test.ts +3 -6
- package/src/apps/funding/routes/transfers.ts +1 -1
- package/src/apps/management/routes/billing.ts +1 -1
- package/src/apps/management/routes/faucet.ts +2 -2
- package/src/apps/management/routes/me.test.ts +31 -0
- package/src/apps/mcp.ts +4 -1
- package/src/apps/mpp/Mpp.test.ts +13 -1
- package/src/apps/mpp/Mpp.ts +8 -0
- package/src/apps/mpp/internal/Adapter.ts +10 -2
- package/src/db/Db.test.ts +40 -6
- package/src/db/Db.ts +114 -19
- package/src/db/Schema.ts +32 -0
- package/src/db/tables/sponsoredTransactions.ts +9 -2
- package/src/db/tables/webhookQueueEvents.ts +285 -0
- package/src/db/tables/webhookSubscriptions.ts +131 -4
- package/src/internal/Auth.test.ts +61 -50
- package/src/internal/Auth.ts +9 -4
- package/src/internal/EarnVaults.test.ts +2 -2
- package/src/internal/MetricSink.test.ts +575 -0
- package/src/internal/MetricSink.ts +325 -10
- package/src/internal/RateLimit.test.ts +23 -18
- package/src/internal/RateLimit.ts +23 -15
- package/src/internal/Schema.ts +1 -1
- package/src/internal/Store.test.ts +99 -13
- package/src/internal/Store.ts +52 -17
- package/src/internal/Tidx.test.ts +4 -4
- package/src/internal/Tidx.ts +1 -3
- package/src/internal/Value.test.ts +25 -0
- package/src/internal/Value.ts +29 -0
- package/src/internal/Viem.test.ts +8 -0
- package/src/internal/Viem.ts +2 -1
- package/src/internal/WebhookDestination.test.ts +20 -0
- package/src/internal/WebhookDestination.ts +10 -0
- package/src/internal/WebhookTransfer.test.ts +89 -0
- package/src/internal/WebhookTransfer.ts +430 -0
- package/src/internal/Webhooks.test.ts +616 -0
- package/src/internal/Webhooks.ts +412 -24
- package/src/internal/funding/Provider.test.ts +48 -8
- package/src/internal/funding/Provider.ts +12 -7
- package/src/internal/funding/Reconciliation.local.test.ts +3 -1
- package/src/internal/funding/Reconciliation.test.ts +1240 -0
- package/src/internal/funding/Reconciliation.ts +136 -0
- package/src/internal/funding/SourceObservation.test.ts +1 -0
- package/src/internal/funding/providers/index.test.ts +73 -3
- package/src/internal/funding/providers/relay.test.ts +2 -9
- package/src/internal/funding/providers/relay.ts +72 -13
|
@@ -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.'),
|
|
@@ -685,7 +664,7 @@ export namespace schema {
|
|
|
685
664
|
.datetime()
|
|
686
665
|
.check(
|
|
687
666
|
z.describe('Block timestamp as an ISO 8601 string.'),
|
|
688
|
-
z.meta({ examples: ['2026-01-14T18:38:03.
|
|
667
|
+
z.meta({ examples: ['2026-01-14T18:38:03.685Z'] }),
|
|
689
668
|
),
|
|
690
669
|
token: z.optional(
|
|
691
670
|
z
|
|
@@ -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
|
|
@@ -800,7 +790,7 @@ export namespace schema {
|
|
|
800
790
|
.datetime()
|
|
801
791
|
.check(
|
|
802
792
|
z.describe('When the block was produced, as an ISO 8601 timestamp.'),
|
|
803
|
-
z.meta({ examples: ['2026-01-14T18:38:03.
|
|
793
|
+
z.meta({ examples: ['2026-01-14T18:38:03.685Z'] }),
|
|
804
794
|
),
|
|
805
795
|
topics: z.array(Schema.Hash).check(
|
|
806
796
|
z.describe('Log topics, starting with the event signature hash (topic 0).'),
|
|
@@ -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
|
|
@@ -891,7 +881,7 @@ export namespace schema {
|
|
|
891
881
|
.datetime()
|
|
892
882
|
.check(
|
|
893
883
|
z.describe('When the block was produced, as an ISO 8601 timestamp.'),
|
|
894
|
-
z.meta({ examples: ['2026-01-14T18:38:03.
|
|
884
|
+
z.meta({ examples: ['2026-01-14T18:38:03.685Z'] }),
|
|
895
885
|
),
|
|
896
886
|
})
|
|
897
887
|
.check(z.describe('Payload for a `block:created` event (a new block header).'))
|
|
@@ -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, { db })
|
|
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,34 @@ 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?:
|
|
2410
|
+
| ((input: readonly Dispatchable[], context: DispatchContext) => Promise<Webhooks.Result>)
|
|
2411
|
+
| undefined
|
|
2159
2412
|
/** Event types to poll (default all). */
|
|
2160
2413
|
eventTypes?: readonly EventType[] | undefined
|
|
2161
2414
|
/** Current chain head; the poller emits up to `head - confirmations`. */
|
|
2162
2415
|
headBlockNumber?: bigint | number | undefined
|
|
2163
2416
|
/** Backfill cap: most blocks an RPC scanner inspects per tick (default 1000). */
|
|
2164
2417
|
maxBlocks?: number | undefined
|
|
2418
|
+
/** Records the worst confirmed-head distance without changing the poll result. */
|
|
2419
|
+
onCursorLag?: ((blocks: number) => void) | undefined
|
|
2420
|
+
/** Records bounded scan inputs before allocation and outcomes after completion. */
|
|
2421
|
+
onScanCheckpoint?: ((checkpoint: ScanCheckpoint) => void) | undefined
|
|
2422
|
+
/** Records bounded, per-tick poll-stage timings without subscription identities. */
|
|
2423
|
+
onStage?: ((stage: Stage) => void) | undefined
|
|
2424
|
+
/** Records subscriptions eligible for this poll before any scan runs. */
|
|
2425
|
+
onSubscriptions?: ((count: number) => void) | undefined
|
|
2165
2426
|
/** Clock for envelope timestamps; injectable for tests. */
|
|
2166
2427
|
now?: (() => Date) | undefined
|
|
2167
|
-
/**
|
|
2168
|
-
|
|
2428
|
+
/** Stops scanning and dispatch when the caller loses its execution lease. */
|
|
2429
|
+
signal?: AbortSignal | undefined
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
/** Runtime context shared with a batch delivery transport. */
|
|
2433
|
+
type DispatchContext = {
|
|
2434
|
+
/** Database instance already resolved for this poll. */
|
|
2435
|
+
db: Db.Db
|
|
2169
2436
|
}
|
|
2170
2437
|
/** Summary counters for one poll tick. */
|
|
2171
2438
|
type Result = {
|
|
@@ -2175,6 +2442,8 @@ export declare namespace poll {
|
|
|
2175
2442
|
dispatched: number
|
|
2176
2443
|
/** Rows whose dispatch failed (pausing that subscription). */
|
|
2177
2444
|
failed: number
|
|
2445
|
+
/** True when a bounded scan left committed work behind the same head. */
|
|
2446
|
+
hasMore?: boolean | undefined
|
|
2178
2447
|
/** Highest confirmed block emitted this tick (`head - confirmations`). */
|
|
2179
2448
|
safeBlock: number
|
|
2180
2449
|
/** Total rows scanned across all subscriptions. */
|
|
@@ -2182,6 +2451,44 @@ export declare namespace poll {
|
|
|
2182
2451
|
/** Active subscriptions visited. */
|
|
2183
2452
|
subscriptions: number
|
|
2184
2453
|
}
|
|
2454
|
+
/** One scan boundary used to correlate production memory failures with workload shape. */
|
|
2455
|
+
type ScanCheckpoint = {
|
|
2456
|
+
/** Shared upper bound for concurrent upstream scans across event types. */
|
|
2457
|
+
concurrency: number
|
|
2458
|
+
/** Scan duration, present only after completion. */
|
|
2459
|
+
durationMs?: number | undefined
|
|
2460
|
+
/** Subscription event type scanned by this batch. */
|
|
2461
|
+
eventType: EventType
|
|
2462
|
+
/** Subscriptions whose scan rejected, present only after completion. */
|
|
2463
|
+
failedSubscriptions?: number | undefined
|
|
2464
|
+
/** Matching rows retained by completed pages, present only after completion. */
|
|
2465
|
+
matchedItems?: number | undefined
|
|
2466
|
+
/** Largest block window requested by one subscription. */
|
|
2467
|
+
maxWindowBlocks: number
|
|
2468
|
+
/** Whether the checkpoint precedes allocation or follows scan completion. */
|
|
2469
|
+
phase: 'complete' | 'start'
|
|
2470
|
+
/** Outcome of the completed RPC-backed scan. */
|
|
2471
|
+
outcome?: 'empty' | 'error' | 'missing' | 'ok' | undefined
|
|
2472
|
+
/** JSON-RPC method used by this event type's scanner. */
|
|
2473
|
+
rpcMethod: 'eth_getBlockByNumber' | 'eth_getLogs'
|
|
2474
|
+
/** Subscriptions retained in this scan batch. */
|
|
2475
|
+
subscriptionCount: number
|
|
2476
|
+
/** Maximum subscriptions retained by this event-type batch. */
|
|
2477
|
+
subscriptionLimit: number
|
|
2478
|
+
/** Sum of subscription block windows in this scan batch. */
|
|
2479
|
+
totalWindowBlocks: number
|
|
2480
|
+
}
|
|
2481
|
+
/** One bounded poll-stage timing observation. */
|
|
2482
|
+
type Stage = {
|
|
2483
|
+
/** Stage duration in milliseconds. */
|
|
2484
|
+
durationMs: number
|
|
2485
|
+
/** Event type being processed, omitted for the shared RPC-head stage. */
|
|
2486
|
+
eventType?: EventType | undefined
|
|
2487
|
+
/** Whether the stage completed. */
|
|
2488
|
+
outcome: 'error' | 'ok'
|
|
2489
|
+
/** Bounded stage name. */
|
|
2490
|
+
stage: MetricSink.PollStage
|
|
2491
|
+
}
|
|
2185
2492
|
}
|
|
2186
2493
|
|
|
2187
2494
|
/**
|
|
@@ -2270,7 +2577,7 @@ function withResolvedFeeToken(data: Record<string, unknown>, metadata: Map<strin
|
|
|
2270
2577
|
return result
|
|
2271
2578
|
}
|
|
2272
2579
|
|
|
2273
|
-
/** Adds best-effort fee-token
|
|
2580
|
+
/** Adds a best-effort fee-token reference to a transaction. */
|
|
2274
2581
|
async function withFeeTokenMetadata(
|
|
2275
2582
|
data: unknown,
|
|
2276
2583
|
deps: {
|
|
@@ -2281,37 +2588,12 @@ async function withFeeTokenMetadata(
|
|
|
2281
2588
|
): Promise<unknown> {
|
|
2282
2589
|
if (typeof data !== 'object' || data === null) return data
|
|
2283
2590
|
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
|
-
}
|
|
2591
|
+
const address = feeTokenAddress(transaction['feeToken'])
|
|
2592
|
+
if (!address) return data
|
|
2593
|
+
const token = await resolveTokenMetadata(address, deps)
|
|
2594
|
+
const metadata = new Map<string, TokenMetadata>()
|
|
2595
|
+
if (token) metadata.set(address.toLowerCase(), token)
|
|
2596
|
+
return withResolvedFeeToken(transaction, metadata)
|
|
2315
2597
|
}
|
|
2316
2598
|
|
|
2317
2599
|
/** Returns the live RPC head block number for the chain. */
|
|
@@ -2319,81 +2601,74 @@ export async function getRpcHead(client: Viem.getClient.ReturnType): Promise<num
|
|
|
2319
2601
|
return Number(await client.getBlockNumber())
|
|
2320
2602
|
}
|
|
2321
2603
|
|
|
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
|
|
2604
|
+
/** Groups aligned cursors only when their bounded upstream scans are equivalent. */
|
|
2605
|
+
async function scanSubscriptions(options: {
|
|
2344
2606
|
client: Viem.getClient.ReturnType
|
|
2345
|
-
|
|
2607
|
+
eventType: EventType
|
|
2346
2608
|
maxBlocks: number
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
}
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2609
|
+
schedule: Schedule
|
|
2610
|
+
states: readonly PollState[]
|
|
2611
|
+
}): Promise<PromiseSettledResult<ScanPage>[]> {
|
|
2612
|
+
const { client, eventType, maxBlocks, schedule, states } = options
|
|
2613
|
+
if (eventType !== 'token:transfer')
|
|
2614
|
+
return scanIndependent({ client, eventType, maxBlocks, schedule, states })
|
|
2615
|
+
return WebhookTransfer.scan({
|
|
2616
|
+
client,
|
|
2617
|
+
concurrency: pollConcurrency,
|
|
2618
|
+
maxBlocks,
|
|
2619
|
+
schedule,
|
|
2620
|
+
states: states.map((state) => ({
|
|
2621
|
+
filters: state.filters,
|
|
2622
|
+
limit: Math.min(scanPageSize, state.remaining),
|
|
2623
|
+
window: scanWindow(state.cursor, state.safeBlock, maxBlocks),
|
|
2624
|
+
})),
|
|
2625
|
+
})
|
|
2626
|
+
}
|
|
2365
2627
|
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
maxBlocks,
|
|
2373
|
-
safeBlock,
|
|
2374
|
-
})
|
|
2628
|
+
/** Returns the bounded JSON-RPC method used by an event scanner. */
|
|
2629
|
+
function scanRpcMethod(eventType: EventType): poll.ScanCheckpoint['rpcMethod'] {
|
|
2630
|
+
return eventType === 'block:created' || eventType === 'transaction:included'
|
|
2631
|
+
? 'eth_getBlockByNumber'
|
|
2632
|
+
: 'eth_getLogs'
|
|
2633
|
+
}
|
|
2375
2634
|
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2635
|
+
/** Classifies a completed batch without exposing an unbounded upstream error. */
|
|
2636
|
+
function scanResultOutcome(
|
|
2637
|
+
pages: readonly PromiseSettledResult<ScanPage>[],
|
|
2638
|
+
): NonNullable<poll.ScanCheckpoint['outcome']> {
|
|
2639
|
+
const rejected = pages.filter((page) => page.status === 'rejected')
|
|
2640
|
+
if (rejected.some((page) => page.reason instanceof BlockUnavailableError)) return 'missing'
|
|
2641
|
+
if (rejected.length > 0) return 'error'
|
|
2642
|
+
return pages.every((page) => page.status === 'fulfilled' && page.value.items.length === 0)
|
|
2643
|
+
? 'empty'
|
|
2644
|
+
: 'ok'
|
|
2645
|
+
}
|
|
2385
2646
|
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2647
|
+
/** Scans CPU-matched streams per subscription while sharing concurrent block reads. */
|
|
2648
|
+
function scanIndependent(options: {
|
|
2649
|
+
client: Viem.getClient.ReturnType
|
|
2650
|
+
eventType: Exclude<EventType, 'token:transfer'>
|
|
2651
|
+
maxBlocks: number
|
|
2652
|
+
schedule: Schedule
|
|
2653
|
+
states: readonly PollState[]
|
|
2654
|
+
}): Promise<PromiseSettledResult<ScanPage>[]> {
|
|
2655
|
+
const { client, eventType, maxBlocks, schedule, states } = options
|
|
2656
|
+
const cache = createChainCache(client)
|
|
2657
|
+
return mapWithLimit(states, pollConcurrency, (state) =>
|
|
2658
|
+
schedule(() => {
|
|
2659
|
+
const shared = {
|
|
2660
|
+
batchSize: Math.min(scanPageSize, state.remaining),
|
|
2661
|
+
cache,
|
|
2662
|
+
cursor: state.cursor,
|
|
2663
|
+
filters: state.filters,
|
|
2664
|
+
maxBlocks,
|
|
2665
|
+
safeBlock: state.safeBlock,
|
|
2666
|
+
}
|
|
2667
|
+
if (eventType === 'block:created') return scanBlocks(shared)
|
|
2668
|
+
if (eventType === 'log:emitted') return scanLogs({ ...shared, client })
|
|
2669
|
+
return scanTxs(shared)
|
|
2670
|
+
}),
|
|
2392
2671
|
)
|
|
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
2672
|
}
|
|
2398
2673
|
|
|
2399
2674
|
const filterSchemas = {
|
|
@@ -2422,18 +2697,6 @@ export function parseFilters(
|
|
|
2422
2697
|
return result.data as Record<string, unknown>
|
|
2423
2698
|
}
|
|
2424
2699
|
|
|
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
2700
|
/** Recursively lowercases string leaves of a filter operator (for address args). */
|
|
2438
2701
|
function lowercaseLeaves(op: unknown): unknown {
|
|
2439
2702
|
if (typeof op === 'string') return op.toLowerCase()
|
|
@@ -2573,62 +2836,49 @@ function atOrBefore(block: number, index: number, skip: readonly [number, number
|
|
|
2573
2836
|
/** A viem-formatted block header (no transactions), as returned by `getBlock`. */
|
|
2574
2837
|
type RpcBlock = Awaited<ReturnType<Viem.getClient.ReturnType['getBlock']>>
|
|
2575
2838
|
|
|
2576
|
-
/** Per-
|
|
2839
|
+
/** Per-batch single-flight cache for chain RPC reads. */
|
|
2577
2840
|
type ChainCache = {
|
|
2578
2841
|
/** Full raw block (with transactions) via `eth_getBlockByNumber(n, true)`. */
|
|
2579
2842
|
block(n: number): Promise<Record<string, unknown>>
|
|
2580
2843
|
/** Viem-formatted block header (transaction hashes only) via `getBlock`. */
|
|
2581
2844
|
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
2845
|
}
|
|
2585
2846
|
|
|
2586
|
-
/** Builds a per-
|
|
2847
|
+
/** Builds a per-batch {@link ChainCache} bound to a viem client. */
|
|
2587
2848
|
function createChainCache(client: Viem.getClient.ReturnType): ChainCache {
|
|
2588
2849
|
const headers = new Map<number, Promise<RpcBlock | undefined>>()
|
|
2589
2850
|
const blocks = new Map<number, Promise<Record<string, unknown>>>()
|
|
2590
|
-
|
|
2851
|
+
|
|
2852
|
+
const singleFlight = <key, value>(
|
|
2853
|
+
pending: Map<key, Promise<value>>,
|
|
2854
|
+
key: key,
|
|
2855
|
+
load: () => Promise<value>,
|
|
2856
|
+
) => {
|
|
2857
|
+
const existing = pending.get(key)
|
|
2858
|
+
if (existing) return existing
|
|
2859
|
+
const promise = load().finally(() => pending.delete(key))
|
|
2860
|
+
pending.set(key, promise)
|
|
2861
|
+
return promise
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2591
2864
|
return {
|
|
2592
2865
|
block(n) {
|
|
2593
|
-
|
|
2594
|
-
if (!p) {
|
|
2866
|
+
return singleFlight(blocks, n, () =>
|
|
2595
2867
|
// The transaction payload reuses `humanizeTransaction`, which consumes
|
|
2596
2868
|
// the raw RPC tx shape, so the full block stays on the untyped path.
|
|
2597
|
-
|
|
2869
|
+
rpc<Record<string, unknown> | null>(client, 'eth_getBlockByNumber', [
|
|
2598
2870
|
blockTag(n),
|
|
2599
2871
|
true,
|
|
2600
2872
|
]).then((block) => {
|
|
2601
2873
|
if (!block) throw new BlockUnavailableError(n)
|
|
2602
2874
|
return block
|
|
2603
|
-
})
|
|
2604
|
-
|
|
2605
|
-
}
|
|
2606
|
-
return p
|
|
2875
|
+
}),
|
|
2876
|
+
)
|
|
2607
2877
|
},
|
|
2608
2878
|
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
|
|
2879
|
+
return singleFlight(headers, n, () =>
|
|
2880
|
+
client.getBlock({ blockNumber: BigInt(n), includeTransactions: false }),
|
|
2881
|
+
)
|
|
2632
2882
|
},
|
|
2633
2883
|
}
|
|
2634
2884
|
}
|
|
@@ -2788,7 +3038,7 @@ function pushTopics(f: z.output<typeof schema.LogFilters>, event: AbiEvent | und
|
|
|
2788
3038
|
pushHex(f.topic2),
|
|
2789
3039
|
pushHex(f.topic3),
|
|
2790
3040
|
]
|
|
2791
|
-
while (topics.at(-1) === undefined) topics.pop()
|
|
3041
|
+
while (topics.length > 0 && topics.at(-1) === undefined) topics.pop()
|
|
2792
3042
|
return topics.map((topic) => topic ?? null)
|
|
2793
3043
|
}
|
|
2794
3044
|
|
|
@@ -2824,17 +3074,7 @@ function decodeScanLog(log: ScanLog, event: AbiEvent): ScanLog {
|
|
|
2824
3074
|
}
|
|
2825
3075
|
}
|
|
2826
3076
|
|
|
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
|
-
*/
|
|
3077
|
+
/** Scans contract logs for one subscription, then re-checks every filter in memory. */
|
|
2838
3078
|
async function scanLogs(options: {
|
|
2839
3079
|
batchSize: number
|
|
2840
3080
|
cache: ChainCache
|
|
@@ -2856,8 +3096,6 @@ async function scanLogs(options: {
|
|
|
2856
3096
|
])
|
|
2857
3097
|
|
|
2858
3098
|
const address = pushAddress(f.address)
|
|
2859
|
-
const fromBlock = BigInt(window.from)
|
|
2860
|
-
const toBlock = BigInt(window.to)
|
|
2861
3099
|
const topics = pushTopics(f, event)
|
|
2862
3100
|
const hasRawTopics = topics.slice(1).some((topic) => topic !== null)
|
|
2863
3101
|
const args = event ? pushEventArgs(f.args, event) : {}
|
|
@@ -2878,16 +3116,13 @@ async function scanLogs(options: {
|
|
|
2878
3116
|
...(address ? { address } : {}),
|
|
2879
3117
|
...(Object.keys(args).length > 0 ? { args: args as never } : {}),
|
|
2880
3118
|
event,
|
|
2881
|
-
fromBlock,
|
|
3119
|
+
fromBlock: BigInt(window.from),
|
|
2882
3120
|
strict: false,
|
|
2883
|
-
toBlock,
|
|
3121
|
+
toBlock: BigInt(window.to),
|
|
2884
3122
|
})) as unknown as ScanLog[]
|
|
2885
|
-
|
|
2886
3123
|
logs.sort((a, b) => {
|
|
2887
|
-
const
|
|
2888
|
-
|
|
2889
|
-
if (ab !== bb) return ab - bb
|
|
2890
|
-
return (a.logIndex ?? 0) - (b.logIndex ?? 0)
|
|
3124
|
+
const block = Number(a.blockNumber ?? 0n) - Number(b.blockNumber ?? 0n)
|
|
3125
|
+
return block || (a.logIndex ?? 0) - (b.logIndex ?? 0)
|
|
2891
3126
|
})
|
|
2892
3127
|
|
|
2893
3128
|
const items: ScanItem[] = []
|
|
@@ -2897,9 +3132,12 @@ async function scanLogs(options: {
|
|
|
2897
3132
|
if (blockNumber === undefined || logIndex === undefined) continue
|
|
2898
3133
|
if (atOrBefore(blockNumber, logIndex, window.skip)) continue
|
|
2899
3134
|
if (!matchesLog(log, f, event)) continue
|
|
2900
|
-
const
|
|
2901
|
-
|
|
2902
|
-
|
|
3135
|
+
const data = toLogEvent(
|
|
3136
|
+
log,
|
|
3137
|
+
Value.blockToIso(await cache.header(blockNumber)),
|
|
3138
|
+
f.signature,
|
|
3139
|
+
event,
|
|
3140
|
+
)
|
|
2903
3141
|
if (data) {
|
|
2904
3142
|
items.push({ cursor: [data.blockNumber, data.logIndex], data })
|
|
2905
3143
|
if (items.length >= batchSize) return { hasMore: true, items }
|
|
@@ -2915,6 +3153,10 @@ function matchesLog(
|
|
|
2915
3153
|
event: AbiEvent | undefined,
|
|
2916
3154
|
): boolean {
|
|
2917
3155
|
const topics = log.topics
|
|
3156
|
+
if (event) {
|
|
3157
|
+
const selector = encodeEventTopics({ abi: [event] })[0]
|
|
3158
|
+
if (topics[0]?.toLowerCase() !== selector.toLowerCase()) return false
|
|
3159
|
+
}
|
|
2918
3160
|
if (f.address !== undefined && !matchesOperator(log.address, f.address)) return false
|
|
2919
3161
|
if (
|
|
2920
3162
|
f.blockNumber !== undefined &&
|
|
@@ -2949,12 +3191,6 @@ function matchesLog(
|
|
|
2949
3191
|
|
|
2950
3192
|
// block:created (getBlock)
|
|
2951
3193
|
|
|
2952
|
-
/** Converts a viem block's `timestamp` (seconds) to an ISO 8601 string. */
|
|
2953
|
-
function blockIso(block: RpcBlock | undefined): string | undefined {
|
|
2954
|
-
if (!block || typeof block.timestamp !== 'bigint') return undefined
|
|
2955
|
-
return new Date(Number(block.timestamp) * 1000).toISOString()
|
|
2956
|
-
}
|
|
2957
|
-
|
|
2958
3194
|
type BlockEventData = {
|
|
2959
3195
|
number: number
|
|
2960
3196
|
hash: string
|
|
@@ -2974,7 +3210,7 @@ function toBlockEvent(block: RpcBlock): BlockEventData | undefined {
|
|
|
2974
3210
|
const miner = Schema.Address.safeParse(block.miner)
|
|
2975
3211
|
const gasUsed = typeof block.gasUsed === 'bigint' ? Number(block.gasUsed) : undefined
|
|
2976
3212
|
const gasLimit = typeof block.gasLimit === 'bigint' ? Number(block.gasLimit) : undefined
|
|
2977
|
-
const timestamp =
|
|
3213
|
+
const timestamp = Value.blockToIso(block)
|
|
2978
3214
|
const transactionCount = Array.isArray(block.transactions) ? block.transactions.length : 0
|
|
2979
3215
|
if (
|
|
2980
3216
|
number === undefined ||
|
|
@@ -2998,11 +3234,7 @@ function toBlockEvent(block: RpcBlock): BlockEventData | undefined {
|
|
|
2998
3234
|
}
|
|
2999
3235
|
}
|
|
3000
3236
|
|
|
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
|
-
*/
|
|
3237
|
+
/** Scans new blocks over one subscription's confirmed window. */
|
|
3006
3238
|
async function scanBlocks(options: {
|
|
3007
3239
|
batchSize: number
|
|
3008
3240
|
cache: ChainCache
|
|
@@ -3017,9 +3249,9 @@ async function scanBlocks(options: {
|
|
|
3017
3249
|
if (!window) return { items: [], scannedThrough: undefined }
|
|
3018
3250
|
|
|
3019
3251
|
const items: ScanItem[] = []
|
|
3020
|
-
for (let
|
|
3021
|
-
if (atOrBefore(
|
|
3022
|
-
const block = await cache.header(
|
|
3252
|
+
for (let blockNumber = window.from; blockNumber <= window.to; blockNumber++) {
|
|
3253
|
+
if (atOrBefore(blockNumber, 0, window.skip)) continue
|
|
3254
|
+
const block = await cache.header(blockNumber)
|
|
3023
3255
|
if (!block) continue
|
|
3024
3256
|
if (!matchesBlock(block, f)) continue
|
|
3025
3257
|
const data = toBlockEvent(block)
|
|
@@ -3053,7 +3285,7 @@ function matchesBlock(block: RpcBlock, f: z.output<typeof schema.BlockFilters>):
|
|
|
3053
3285
|
return true
|
|
3054
3286
|
}
|
|
3055
3287
|
|
|
3056
|
-
// transaction:included (eth_getBlockByNumber
|
|
3288
|
+
// transaction:included (eth_getBlockByNumber)
|
|
3057
3289
|
|
|
3058
3290
|
/** Maps a `transaction:included` scalar filter field to its raw-tx field + kind. */
|
|
3059
3291
|
const txFields = {
|
|
@@ -3071,13 +3303,7 @@ const txFields = {
|
|
|
3071
3303
|
value: { field: 'value', kind: 'number' },
|
|
3072
3304
|
} as const satisfies Record<string, { field: string; kind: 'number' | 'string' }>
|
|
3073
3305
|
|
|
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
|
-
*/
|
|
3306
|
+
/** Scans included transactions over one subscription's confirmed window. */
|
|
3081
3307
|
async function scanTxs(options: {
|
|
3082
3308
|
batchSize: number
|
|
3083
3309
|
cache: ChainCache
|
|
@@ -3092,55 +3318,37 @@ async function scanTxs(options: {
|
|
|
3092
3318
|
if (!window) return { items: [], scannedThrough: undefined }
|
|
3093
3319
|
|
|
3094
3320
|
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
3321
|
const items: ScanItem[] = []
|
|
3101
|
-
for (let
|
|
3102
|
-
const block = await cache.block(
|
|
3103
|
-
const
|
|
3104
|
-
|
|
3105
|
-
|
|
3322
|
+
for (let blockNumber = window.from; blockNumber <= window.to; blockNumber++) {
|
|
3323
|
+
const block = await cache.block(blockNumber)
|
|
3324
|
+
const transactions = Array.isArray(block['transactions'])
|
|
3325
|
+
? (block['transactions'] as unknown[])
|
|
3326
|
+
: []
|
|
3106
3327
|
const timestamp = asString(block['timestamp'])
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
const
|
|
3113
|
-
if (
|
|
3114
|
-
if (!matchesTx(
|
|
3115
|
-
const parsed = Transactions.schema.Rpc.Transaction.safeParse(
|
|
3328
|
+
// A transaction's own `blockTimestamp` is seconds-only, so the delivered
|
|
3329
|
+
// timestamp comes from the enclosing block, which carries milliseconds.
|
|
3330
|
+
const blockTime = Value.blockToIso(block)
|
|
3331
|
+
for (let offset = 0; offset < transactions.length; offset++) {
|
|
3332
|
+
const transaction = transactions[offset] as Record<string, unknown>
|
|
3333
|
+
const transactionIndex = Value.hexToNumber(transaction['transactionIndex']) ?? offset
|
|
3334
|
+
if (atOrBefore(blockNumber, transactionIndex, window.skip)) continue
|
|
3335
|
+
if (!matchesTx(transaction, f, includeCalls, timestamp, blockNumber)) continue
|
|
3336
|
+
const parsed = Transactions.schema.Rpc.Transaction.safeParse(transaction)
|
|
3116
3337
|
if (!parsed.success) continue
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
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 })
|
|
3338
|
+
const humanized = Transactions.humanizeTransaction(parsed.data)
|
|
3339
|
+
const data = blockTime === undefined ? humanized : { ...humanized, timestamp: blockTime }
|
|
3340
|
+
items.push({ cursor: [blockNumber, transactionIndex], data })
|
|
3132
3341
|
if (items.length >= batchSize) return { hasMore: true, items }
|
|
3133
3342
|
}
|
|
3134
3343
|
}
|
|
3135
3344
|
return { items, scannedThrough: window.to }
|
|
3136
3345
|
}
|
|
3137
3346
|
|
|
3138
|
-
/** Re-checks
|
|
3347
|
+
/** Re-checks transaction filters against the raw RPC transaction. */
|
|
3139
3348
|
function matchesTx(
|
|
3140
3349
|
tx: Record<string, unknown>,
|
|
3141
3350
|
f: z.output<typeof schema.TxFilters>,
|
|
3142
3351
|
includeCalls: boolean,
|
|
3143
|
-
receipt: Record<string, unknown> | undefined,
|
|
3144
3352
|
timestamp: string | undefined,
|
|
3145
3353
|
blockNumber: number,
|
|
3146
3354
|
): boolean {
|
|
@@ -3176,19 +3384,6 @@ function matchesTx(
|
|
|
3176
3384
|
|
|
3177
3385
|
if (f.timestamp !== undefined && !matchesOperator(timestamp, f.timestamp, 'number')) return false
|
|
3178
3386
|
|
|
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
3387
|
return true
|
|
3193
3388
|
}
|
|
3194
3389
|
|
|
@@ -3298,6 +3493,202 @@ function unauthorized(c: Context<App.Environment>) {
|
|
|
3298
3493
|
})
|
|
3299
3494
|
}
|
|
3300
3495
|
|
|
3496
|
+
// Pure per-block scanning (no RPC, no cursors, no pagination).
|
|
3497
|
+
|
|
3498
|
+
// Pure over its inputs: the caller fetches the raw block (with transactions)
|
|
3499
|
+
// and its logs; enrichment happens downstream. Rows require a parsable block number.
|
|
3500
|
+
/**
|
|
3501
|
+
* Matches one fetched block against a subscription set, producing the rows
|
|
3502
|
+
* each event type delivers. Invalid stored filters fail closed into `invalid`.
|
|
3503
|
+
*/
|
|
3504
|
+
export function scanBlock<const subscription extends scanBlock.Subscription>(
|
|
3505
|
+
options: scanBlock.Options<subscription>,
|
|
3506
|
+
): scanBlock.Result<subscription> {
|
|
3507
|
+
const { block, logs, subscriptions } = options
|
|
3508
|
+
const blockNumber = Value.hexToNumber(block['number'])
|
|
3509
|
+
const timestamp = Value.blockToIso(block)
|
|
3510
|
+
const header = toScanHeader(block)
|
|
3511
|
+
const viemLogs = logs.map(fromRpcLog)
|
|
3512
|
+
const matches: scanBlock.Match<subscription>[] = []
|
|
3513
|
+
const invalid: scanBlock.Invalid<subscription>[] = []
|
|
3514
|
+
|
|
3515
|
+
for (const subscription of subscriptions) {
|
|
3516
|
+
const filters = (() => {
|
|
3517
|
+
try {
|
|
3518
|
+
return parseFilters(subscription.eventType, subscription.filters)
|
|
3519
|
+
} catch (error) {
|
|
3520
|
+
invalid.push({ error, subscription })
|
|
3521
|
+
return undefined
|
|
3522
|
+
}
|
|
3523
|
+
})()
|
|
3524
|
+
if (filters === undefined) continue
|
|
3525
|
+
const matched = matches.length
|
|
3526
|
+
const push = (data: unknown, logIndex: number) => {
|
|
3527
|
+
if (blockNumber !== undefined) matches.push({ blockNumber, data, logIndex, subscription })
|
|
3528
|
+
}
|
|
3529
|
+
|
|
3530
|
+
// A throwing matcher invalidates only this subscription, rolling back any
|
|
3531
|
+
// rows it matched before the failure.
|
|
3532
|
+
try {
|
|
3533
|
+
switch (subscription.eventType) {
|
|
3534
|
+
case 'block:created': {
|
|
3535
|
+
const f = filters as z.output<typeof schema.BlockFilters>
|
|
3536
|
+
if (!matchesBlock(header, f)) break
|
|
3537
|
+
const data = toBlockEvent(header)
|
|
3538
|
+
if (data) push(data, 0)
|
|
3539
|
+
break
|
|
3540
|
+
}
|
|
3541
|
+
case 'log:emitted': {
|
|
3542
|
+
const f = filters as z.output<typeof schema.LogFilters>
|
|
3543
|
+
const event = parseEvent(f.signature)
|
|
3544
|
+
if (f.signature && (!event || !/^[A-Za-z_]\w*$/.test(event.name))) {
|
|
3545
|
+
invalid.push({
|
|
3546
|
+
error: new Webhooks.InvalidFilterError('log:emitted', [
|
|
3547
|
+
{ message: 'Unsupported event signature.', path: ['signature'] },
|
|
3548
|
+
]),
|
|
3549
|
+
subscription,
|
|
3550
|
+
})
|
|
3551
|
+
break
|
|
3552
|
+
}
|
|
3553
|
+
// Validate arg names up front so a bad filter fails closed even
|
|
3554
|
+
// when the block has no decodable logs.
|
|
3555
|
+
const unknown =
|
|
3556
|
+
event && f.args
|
|
3557
|
+
? Object.keys(f.args).find(
|
|
3558
|
+
(name) => !event.inputs.some((input) => input.name === name),
|
|
3559
|
+
)
|
|
3560
|
+
: undefined
|
|
3561
|
+
if (unknown !== undefined) {
|
|
3562
|
+
invalid.push({
|
|
3563
|
+
error: new Webhooks.InvalidFilterError('log:emitted', [
|
|
3564
|
+
{ message: `Unknown event argument: ${unknown}.`, path: ['args', unknown] },
|
|
3565
|
+
]),
|
|
3566
|
+
subscription,
|
|
3567
|
+
})
|
|
3568
|
+
break
|
|
3569
|
+
}
|
|
3570
|
+
for (const log of viemLogs) {
|
|
3571
|
+
const decoded = event ? decodeScanLog(log, event) : log
|
|
3572
|
+
const logIndex = decoded.logIndex ?? undefined
|
|
3573
|
+
if (logIndex === undefined) continue
|
|
3574
|
+
if (!matchesLog(decoded, f, event)) continue
|
|
3575
|
+
const data = toLogEvent(decoded, timestamp, f.signature, event)
|
|
3576
|
+
if (data) push(data, logIndex)
|
|
3577
|
+
}
|
|
3578
|
+
break
|
|
3579
|
+
}
|
|
3580
|
+
case 'token:transfer': {
|
|
3581
|
+
if (timestamp === undefined) break
|
|
3582
|
+
const f = filters as WebhookTransfer.Filters
|
|
3583
|
+
for (const log of logs) {
|
|
3584
|
+
const data = WebhookTransfer.decode(log, timestamp)
|
|
3585
|
+
if (!data || !WebhookTransfer.matches(data, f)) continue
|
|
3586
|
+
push(data, data.logIndex)
|
|
3587
|
+
}
|
|
3588
|
+
break
|
|
3589
|
+
}
|
|
3590
|
+
case 'transaction:included': {
|
|
3591
|
+
const f = filters as z.output<typeof schema.TxFilters>
|
|
3592
|
+
const includeCalls = f.includeCalls ?? true
|
|
3593
|
+
const transactions = Array.isArray(block['transactions'])
|
|
3594
|
+
? (block['transactions'] as unknown[])
|
|
3595
|
+
: []
|
|
3596
|
+
const rawTimestamp = asString(block['timestamp'])
|
|
3597
|
+
for (let offset = 0; offset < transactions.length; offset++) {
|
|
3598
|
+
const transaction = transactions[offset]
|
|
3599
|
+
if (!transaction || typeof transaction !== 'object') continue
|
|
3600
|
+
const tx = transaction as Record<string, unknown>
|
|
3601
|
+
const transactionIndex = Value.hexToNumber(tx['transactionIndex']) ?? offset
|
|
3602
|
+
if (!matchesTx(tx, f, includeCalls, rawTimestamp, blockNumber ?? 0)) continue
|
|
3603
|
+
const parsed = Transactions.schema.Rpc.Transaction.safeParse(tx)
|
|
3604
|
+
if (!parsed.success) continue
|
|
3605
|
+
const humanized = Transactions.humanizeTransaction(parsed.data)
|
|
3606
|
+
// A transaction's own timestamp is seconds-only; the enclosing
|
|
3607
|
+
// block carries milliseconds.
|
|
3608
|
+
push(
|
|
3609
|
+
timestamp === undefined ? humanized : { ...humanized, timestamp },
|
|
3610
|
+
transactionIndex,
|
|
3611
|
+
)
|
|
3612
|
+
}
|
|
3613
|
+
break
|
|
3614
|
+
}
|
|
3615
|
+
}
|
|
3616
|
+
} catch (error) {
|
|
3617
|
+
matches.length = matched
|
|
3618
|
+
invalid.push({ error, subscription })
|
|
3619
|
+
}
|
|
3620
|
+
}
|
|
3621
|
+
return { invalid, matches }
|
|
3622
|
+
}
|
|
3623
|
+
|
|
3624
|
+
/** Normalizes a raw RPC header to the viem-shaped fields the block matchers read. */
|
|
3625
|
+
function toScanHeader(block: Record<string, unknown>): RpcBlock {
|
|
3626
|
+
const quantity = (value: unknown) => {
|
|
3627
|
+
try {
|
|
3628
|
+
return typeof value === 'string' || typeof value === 'number' || typeof value === 'bigint'
|
|
3629
|
+
? BigInt(value)
|
|
3630
|
+
: undefined
|
|
3631
|
+
} catch {
|
|
3632
|
+
return undefined
|
|
3633
|
+
}
|
|
3634
|
+
}
|
|
3635
|
+
return {
|
|
3636
|
+
...block,
|
|
3637
|
+
gasLimit: quantity(block['gasLimit']),
|
|
3638
|
+
gasUsed: quantity(block['gasUsed']),
|
|
3639
|
+
number: quantity(block['number']),
|
|
3640
|
+
timestamp: quantity(block['timestamp']),
|
|
3641
|
+
} as RpcBlock
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
export declare namespace scanBlock {
|
|
3645
|
+
/** The subscription fields matching consumes; extra fields flow through. */
|
|
3646
|
+
type Subscription = {
|
|
3647
|
+
/** Event type selecting the matcher and payload shape. */
|
|
3648
|
+
eventType: EventType
|
|
3649
|
+
/** Stored filter predicates, validated fail-closed per event type. */
|
|
3650
|
+
filters: Record<string, unknown>
|
|
3651
|
+
}
|
|
3652
|
+
|
|
3653
|
+
/** One fetched block plus the subscriptions to match it against. */
|
|
3654
|
+
type Options<subscription extends Subscription> = {
|
|
3655
|
+
/** Raw `eth_getBlockByNumber(_, true)` result: header plus raw transactions. */
|
|
3656
|
+
block: Record<string, unknown>
|
|
3657
|
+
/** Raw `eth_getLogs` entries for exactly this block, in log order. */
|
|
3658
|
+
logs: readonly RpcLog[]
|
|
3659
|
+
/** Active subscriptions for the chain; any mix of event types. */
|
|
3660
|
+
subscriptions: readonly subscription[]
|
|
3661
|
+
}
|
|
3662
|
+
|
|
3663
|
+
/** One deliverable row for one subscription. */
|
|
3664
|
+
type Match<subscription extends Subscription> = {
|
|
3665
|
+
/** Block the row was produced from. */
|
|
3666
|
+
blockNumber: number
|
|
3667
|
+
/** Payload the matching read endpoint returns (pre-enrichment). */
|
|
3668
|
+
data: unknown
|
|
3669
|
+
/** Row position within the block (log or transaction index; 0 for blocks). */
|
|
3670
|
+
logIndex: number
|
|
3671
|
+
/** The matched subscription, passed through verbatim. */
|
|
3672
|
+
subscription: subscription
|
|
3673
|
+
}
|
|
3674
|
+
|
|
3675
|
+
/** A subscription whose stored filters failed validation. */
|
|
3676
|
+
type Invalid<subscription extends Subscription> = {
|
|
3677
|
+
/** The validation failure. */
|
|
3678
|
+
error: unknown
|
|
3679
|
+
/** The subscription with invalid filters. */
|
|
3680
|
+
subscription: subscription
|
|
3681
|
+
}
|
|
3682
|
+
|
|
3683
|
+
/** Matches plus fail-closed filter rejections. */
|
|
3684
|
+
type Result<subscription extends Subscription> = {
|
|
3685
|
+
/** Subscriptions skipped because their stored filters failed validation. */
|
|
3686
|
+
invalid: readonly Invalid<subscription>[]
|
|
3687
|
+
/** Deliverable rows in subscription order, block order within each type. */
|
|
3688
|
+
matches: readonly Match<subscription>[]
|
|
3689
|
+
}
|
|
3690
|
+
}
|
|
3691
|
+
|
|
3301
3692
|
class BlockUnavailableError extends Error {
|
|
3302
3693
|
override name = 'Webhooks.BlockUnavailableError'
|
|
3303
3694
|
|