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.
Files changed (198) hide show
  1. package/dist/App.d.ts +4887 -5074
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/Webhooks.d.ts +21 -20
  4. package/dist/Webhooks.d.ts.map +1 -1
  5. package/dist/Webhooks.js +66 -23
  6. package/dist/Webhooks.js.map +1 -1
  7. package/dist/admin/apps/verified-tokens.d.ts +168 -168
  8. package/dist/apps/Zones.d.ts +34 -22
  9. package/dist/apps/Zones.d.ts.map +1 -1
  10. package/dist/apps/Zones.js +9 -8
  11. package/dist/apps/Zones.js.map +1 -1
  12. package/dist/apps/data/App.d.ts +5198 -5099
  13. package/dist/apps/data/App.d.ts.map +1 -1
  14. package/dist/apps/data/FxOracle.d.ts +4 -5
  15. package/dist/apps/data/FxOracle.d.ts.map +1 -1
  16. package/dist/apps/data/FxOracle.js +19 -4
  17. package/dist/apps/data/FxOracle.js.map +1 -1
  18. package/dist/apps/data/routes/activities.d.ts +270 -270
  19. package/dist/apps/data/routes/activities.d.ts.map +1 -1
  20. package/dist/apps/data/routes/activities.js +32 -27
  21. package/dist/apps/data/routes/activities.js.map +1 -1
  22. package/dist/apps/data/routes/addresses.d.ts +52 -52
  23. package/dist/apps/data/routes/balances.d.ts +36 -36
  24. package/dist/apps/data/routes/balances.d.ts.map +1 -1
  25. package/dist/apps/data/routes/balances.js +135 -81
  26. package/dist/apps/data/routes/balances.js.map +1 -1
  27. package/dist/apps/data/routes/blocks.d.ts +125 -125
  28. package/dist/apps/data/routes/coingecko.d.ts +54 -54
  29. package/dist/apps/data/routes/earn.d.ts +512 -58
  30. package/dist/apps/data/routes/earn.d.ts.map +1 -1
  31. package/dist/apps/data/routes/earn.js +368 -44
  32. package/dist/apps/data/routes/earn.js.map +1 -1
  33. package/dist/apps/data/routes/exchanges.d.ts +120 -120
  34. package/dist/apps/data/routes/exchanges.d.ts.map +1 -1
  35. package/dist/apps/data/routes/exchanges.js +114 -118
  36. package/dist/apps/data/routes/exchanges.js.map +1 -1
  37. package/dist/apps/data/routes/receipts.d.ts.map +1 -1
  38. package/dist/apps/data/routes/receipts.js +68 -47
  39. package/dist/apps/data/routes/receipts.js.map +1 -1
  40. package/dist/apps/data/routes/rpc.d.ts +31 -31
  41. package/dist/apps/data/routes/tokens.d.ts +461 -459
  42. package/dist/apps/data/routes/tokens.d.ts.map +1 -1
  43. package/dist/apps/data/routes/transactions.d.ts +59 -51
  44. package/dist/apps/data/routes/transactions.d.ts.map +1 -1
  45. package/dist/apps/data/routes/transactions.js +25 -23
  46. package/dist/apps/data/routes/transactions.js.map +1 -1
  47. package/dist/apps/data/routes/transfers.d.ts +108 -86
  48. package/dist/apps/data/routes/transfers.d.ts.map +1 -1
  49. package/dist/apps/data/routes/transfers.js +618 -105
  50. package/dist/apps/data/routes/transfers.js.map +1 -1
  51. package/dist/apps/data/routes/valuation.d.ts +21 -21
  52. package/dist/apps/data/routes/valuation.d.ts.map +1 -1
  53. package/dist/apps/data/routes/valuation.js +2 -2
  54. package/dist/apps/data/routes/valuation.js.map +1 -1
  55. package/dist/apps/data/routes/verified-tokens.d.ts +37 -37
  56. package/dist/apps/data/routes/webhooks.d.ts +102 -505
  57. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  58. package/dist/apps/data/routes/webhooks.js +484 -342
  59. package/dist/apps/data/routes/webhooks.js.map +1 -1
  60. package/dist/apps/data/routes/zones.d.ts +12 -12
  61. package/dist/apps/data/routes/zones.d.ts.map +1 -1
  62. package/dist/apps/data/routes/zones.js +3 -1
  63. package/dist/apps/data/routes/zones.js.map +1 -1
  64. package/dist/apps/funding/App.d.ts +96 -96
  65. package/dist/apps/funding/routes/deposit-addresses.d.ts +52 -52
  66. package/dist/apps/funding/routes/quotes.d.ts +89 -89
  67. package/dist/apps/funding/routes/transfers.d.ts +4 -4
  68. package/dist/apps/management/App.d.ts +429 -727
  69. package/dist/apps/management/App.d.ts.map +1 -1
  70. package/dist/apps/management/routes/api-keys.d.ts +24 -24
  71. package/dist/apps/management/routes/billing.d.ts +72 -72
  72. package/dist/apps/management/routes/faucet.d.ts +6 -6
  73. package/dist/apps/management/routes/invitations.d.ts +134 -134
  74. package/dist/apps/management/routes/invite-links.d.ts +38 -38
  75. package/dist/apps/management/routes/me.d.ts +6 -6
  76. package/dist/apps/management/routes/members.d.ts +30 -30
  77. package/dist/apps/management/routes/orgs.d.ts +98 -98
  78. package/dist/apps/management/routes/projects.d.ts +20 -20
  79. package/dist/apps/management/routes/usage.d.ts +24 -24
  80. package/dist/apps/management/routes/verified-token-requests.d.ts +83 -83
  81. package/dist/apps/management/routes/webhooks.d.ts +158 -573
  82. package/dist/apps/management/routes/webhooks.d.ts.map +1 -1
  83. package/dist/apps/mpp/App.d.ts +56 -44
  84. package/dist/apps/mpp/App.d.ts.map +1 -1
  85. package/dist/apps/mpp/Mpp.d.ts +3 -0
  86. package/dist/apps/mpp/Mpp.d.ts.map +1 -1
  87. package/dist/apps/mpp/Mpp.js +3 -0
  88. package/dist/apps/mpp/Mpp.js.map +1 -1
  89. package/dist/apps/mpp/internal/Adapter.d.ts.map +1 -1
  90. package/dist/apps/mpp/internal/Adapter.js +9 -2
  91. package/dist/apps/mpp/internal/Adapter.js.map +1 -1
  92. package/dist/db/Db.d.ts +2 -2
  93. package/dist/db/Db.d.ts.map +1 -1
  94. package/dist/db/Db.js +17 -15
  95. package/dist/db/Db.js.map +1 -1
  96. package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
  97. package/dist/db/tables/sponsoredTransactions.js +4 -1
  98. package/dist/db/tables/sponsoredTransactions.js.map +1 -1
  99. package/dist/db/tables/webhookSubscriptions.d.ts +56 -3
  100. package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -1
  101. package/dist/db/tables/webhookSubscriptions.js +76 -3
  102. package/dist/db/tables/webhookSubscriptions.js.map +1 -1
  103. package/dist/internal/MetricSink.d.ts +112 -3
  104. package/dist/internal/MetricSink.d.ts.map +1 -1
  105. package/dist/internal/MetricSink.js +116 -3
  106. package/dist/internal/MetricSink.js.map +1 -1
  107. package/dist/internal/Schema.js +1 -1
  108. package/dist/internal/Schema.js.map +1 -1
  109. package/dist/internal/Tidx.d.ts +1 -3
  110. package/dist/internal/Tidx.d.ts.map +1 -1
  111. package/dist/internal/Tidx.js +1 -3
  112. package/dist/internal/Tidx.js.map +1 -1
  113. package/dist/internal/Viem.d.ts.map +1 -1
  114. package/dist/internal/Viem.js +2 -1
  115. package/dist/internal/Viem.js.map +1 -1
  116. package/dist/internal/WebhookDestination.d.ts +2 -0
  117. package/dist/internal/WebhookDestination.d.ts.map +1 -1
  118. package/dist/internal/WebhookDestination.js +11 -0
  119. package/dist/internal/WebhookDestination.js.map +1 -1
  120. package/dist/internal/WebhookTransfer.d.ts +55 -0
  121. package/dist/internal/WebhookTransfer.d.ts.map +1 -0
  122. package/dist/internal/WebhookTransfer.js +296 -0
  123. package/dist/internal/WebhookTransfer.js.map +1 -0
  124. package/dist/internal/Webhooks.d.ts +37 -0
  125. package/dist/internal/Webhooks.d.ts.map +1 -1
  126. package/dist/internal/Webhooks.js +87 -13
  127. package/dist/internal/Webhooks.js.map +1 -1
  128. package/dist/internal/funding/Provider.d.ts +4 -0
  129. package/dist/internal/funding/Provider.d.ts.map +1 -1
  130. package/dist/internal/funding/Provider.js +11 -7
  131. package/dist/internal/funding/Provider.js.map +1 -1
  132. package/dist/internal/funding/Reconciliation.d.ts.map +1 -1
  133. package/dist/internal/funding/Reconciliation.js +128 -0
  134. package/dist/internal/funding/Reconciliation.js.map +1 -1
  135. package/dist/internal/funding/providers/relay.d.ts +3 -0
  136. package/dist/internal/funding/providers/relay.d.ts.map +1 -1
  137. package/dist/internal/funding/providers/relay.js +12 -1
  138. package/dist/internal/funding/providers/relay.js.map +1 -1
  139. package/package.json +3 -3
  140. package/src/Client.test-d.ts +60 -5
  141. package/src/Webhooks.bench.ts +714 -0
  142. package/src/Webhooks.memory.bench.ts +250 -0
  143. package/src/Webhooks.test.ts +77 -6
  144. package/src/Webhooks.ts +83 -29
  145. package/src/apps/Zones.test.ts +5 -4
  146. package/src/apps/Zones.ts +9 -8
  147. package/src/apps/data/FxOracle.test.ts +18 -0
  148. package/src/apps/data/FxOracle.ts +28 -5
  149. package/src/apps/data/routes/activities.test.ts +19 -0
  150. package/src/apps/data/routes/activities.ts +34 -30
  151. package/src/apps/data/routes/balances.test.ts +119 -10
  152. package/src/apps/data/routes/balances.ts +154 -95
  153. package/src/apps/data/routes/blocks.test.ts +12 -6
  154. package/src/apps/data/routes/earn.test.ts +197 -0
  155. package/src/apps/data/routes/earn.ts +486 -44
  156. package/src/apps/data/routes/exchanges.test.ts +7 -6
  157. package/src/apps/data/routes/exchanges.ts +124 -117
  158. package/src/apps/data/routes/receipts.test.ts +24 -7
  159. package/src/apps/data/routes/receipts.ts +67 -50
  160. package/src/apps/data/routes/tokens.test.ts +51 -0
  161. package/src/apps/data/routes/transactions.test.ts +42 -0
  162. package/src/apps/data/routes/transactions.ts +44 -39
  163. package/src/apps/data/routes/transfers.test.ts +402 -24
  164. package/src/apps/data/routes/transfers.ts +708 -101
  165. package/src/apps/data/routes/valuation.test.ts +26 -0
  166. package/src/apps/data/routes/valuation.ts +6 -2
  167. package/src/apps/data/routes/webhooks.test.ts +414 -130
  168. package/src/apps/data/routes/webhooks.ts +603 -419
  169. package/src/apps/data/routes/zones.ts +6 -1
  170. package/src/apps/funding/routes/quotes.test.ts +1 -1
  171. package/src/apps/mpp/Mpp.test.ts +13 -1
  172. package/src/apps/mpp/Mpp.ts +8 -0
  173. package/src/apps/mpp/internal/Adapter.ts +10 -2
  174. package/src/db/Db.test.ts +40 -6
  175. package/src/db/Db.ts +33 -19
  176. package/src/db/tables/sponsoredTransactions.ts +9 -2
  177. package/src/db/tables/webhookSubscriptions.ts +116 -4
  178. package/src/internal/EarnVaults.test.ts +2 -2
  179. package/src/internal/MetricSink.test.ts +518 -0
  180. package/src/internal/MetricSink.ts +266 -9
  181. package/src/internal/Schema.ts +1 -1
  182. package/src/internal/Tidx.test.ts +4 -4
  183. package/src/internal/Tidx.ts +1 -3
  184. package/src/internal/Viem.test.ts +8 -0
  185. package/src/internal/Viem.ts +2 -1
  186. package/src/internal/WebhookDestination.test.ts +20 -0
  187. package/src/internal/WebhookDestination.ts +10 -0
  188. package/src/internal/WebhookTransfer.ts +426 -0
  189. package/src/internal/Webhooks.test.ts +137 -0
  190. package/src/internal/Webhooks.ts +131 -17
  191. package/src/internal/funding/Provider.test.ts +47 -7
  192. package/src/internal/funding/Provider.ts +12 -7
  193. package/src/internal/funding/Reconciliation.local.test.ts +3 -1
  194. package/src/internal/funding/Reconciliation.test.ts +1240 -0
  195. package/src/internal/funding/Reconciliation.ts +136 -0
  196. package/src/internal/funding/SourceObservation.test.ts +1 -0
  197. package/src/internal/funding/providers/relay.test.ts +1 -4
  198. package/src/internal/funding/providers/relay.ts +16 -1
@@ -12,8 +12,8 @@ import * as Store from '../../../internal/Store.js';
12
12
  import * as Ttl from '../../../internal/Ttl.js';
13
13
  import * as Value from '../../../internal/Value.js';
14
14
  import * as WebhookDestination from '../../../internal/WebhookDestination.js';
15
+ import * as WebhookTransfer from '../../../internal/WebhookTransfer.js';
15
16
  import * as Webhooks from '../../../internal/Webhooks.js';
16
- import * as Receipts from './receipts.js';
17
17
  import * as Transactions from './transactions.js';
18
18
  import * as Transfers from './transfers.js';
19
19
  /** Default cap on live subscriptions per owner, overridable via `webhook.maxPerOwner`. */
@@ -52,7 +52,7 @@ const eventTypeDescriptions = [
52
52
  type: 'token:transfer',
53
53
  },
54
54
  {
55
- description: 'A transaction included in a block, filterable on every transaction field (sender, recipient, value, calldata, inner account-abstraction calls, fees, nonce) and its execution status.',
55
+ description: 'A transaction included in a block, filterable on transaction fields such as sender, recipient, value, calldata, inner account-abstraction calls, fees, and nonce.',
56
56
  type: 'transaction:included',
57
57
  },
58
58
  ];
@@ -173,13 +173,12 @@ export var schema;
173
173
  schema.TransferFilters = Transfers.schema.Predicates.check(z.describe('Filters that narrow which TIP-20 transfer events trigger this webhook.'), z.meta({ title: 'Transfer filters' }));
174
174
  /**
175
175
  * `transaction:included` subscription filters: operators over every column the
176
- * `txs` envelope exposes, plus inner account-abstraction `calls`, the
177
- * top-level `input` calldata, and the receipt-sourced execution `status`. All
178
- * optional and AND-combined; omit every field for the full stream.
176
+ * `txs` envelope exposes, plus inner account-abstraction `calls` and the
177
+ * top-level `input` calldata. All optional and AND-combined; omit every field
178
+ * for the full stream.
179
179
  *
180
180
  * `address` matches either side (`from` OR `to`); when `includeCalls` is set
181
181
  * (default), `to`/`address` also match an inner call target (`calls[].to`).
182
- * Set `includeReceipt` to embed the receipt in the delivered payload.
183
182
  */
184
183
  schema.TxFilters = z
185
184
  .strictObject({
@@ -190,9 +189,6 @@ export var schema;
190
189
  includeCalls: z.optional(z
191
190
  .boolean()
192
191
  .check(z.describe('When true (default), `to`/`address` also match an inner account-abstraction call target (`calls[].to`).'), z.meta({ examples: [true] }))),
193
- includeReceipt: z.optional(z
194
- .boolean()
195
- .check(z.describe('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`.'), z.meta({ examples: [true] }))),
196
192
  value: z.optional(schema.compare.check(z.describe('Match the transaction value (wei) bounds.'))),
197
193
  input: z.optional(schema.bytes.check(z.describe('Match top-level calldata by 4-byte `selector`, hex `startsWith`, or exact.'))),
198
194
  calls: z.optional(z
@@ -203,9 +199,7 @@ export var schema;
203
199
  callCount: z.optional(schema.compare.check(z.describe('Match the number of inner calls (`call_count`).'))),
204
200
  txType: z.optional(schema.eq(Schema.Hex).check(z.describe('Match the transaction type (e.g. `0x76` for Tempo AA).'))),
205
201
  feeToken: z.optional(schema.eq(Schema.TokenAddress).check(z.describe('Match the fee token address.'))),
206
- feePayer: z.optional(schema.eq(Schema.Address).check(z.describe('Match the fee payer address.'))),
207
202
  gasLimit: z.optional(schema.compare.check(z.describe('Match the gas limit bounds.'))),
208
- gasUsed: z.optional(schema.compare.check(z.describe('Match the gas used bounds.'))),
209
203
  maxFeePerGas: z.optional(schema.compare.check(z.describe('Match the max fee per gas bounds.'))),
210
204
  maxPriorityFeePerGas: z.optional(schema.compare.check(z.describe('Match the max priority fee per gas bounds.'))),
211
205
  nonce: z.optional(schema.compare.check(z.describe('Match the nonce bounds.'))),
@@ -214,9 +208,6 @@ export var schema;
214
208
  validAfter: z.optional(schema.compare.check(z.describe('Match the `validAfter` bounds (unix s).'))),
215
209
  blockNumber: z.optional(schema.compare.check(z.describe('Match the block-number bounds.'))),
216
210
  timestamp: z.optional(schema.compare.check(z.describe('Match the block timestamp bounds (unix s).'))),
217
- status: z.optional(z
218
- .enum(['success', 'reverted'])
219
- .check(z.describe('Match the receipt execution status (routes through `receipts`).'), z.meta({ examples: ['success'] }))),
220
211
  })
221
212
  .check(z.describe('Filters that narrow which included transactions trigger this webhook.'), z.meta({ title: 'Included-transaction filters' }));
222
213
  /** A 32-byte log topic (lowercased hex). */
@@ -402,10 +393,19 @@ export var schema;
402
393
  })
403
394
  .check(z.describe('Payload for a `token:transfer` event, decoded from a TIP-20 `Transfer` log.'));
404
395
  /**
405
- * A `transaction` event payload: identical to the `GET /transactions/:hash`
406
- * response (the poller reuses the same humanized shape).
396
+ * A `transaction` event payload using the humanized transaction shape without
397
+ * opt-in read resources such as receipts.
407
398
  */
408
- schema.TransactionEvent = Transactions.schema.getTransaction.Response;
399
+ schema.TransactionEvent = z
400
+ .object({
401
+ ...Transactions.schema.Transaction.shape,
402
+ meta: z
403
+ .object({
404
+ rpc: Transactions.schema.Rpc.Transaction.check(z.describe('The original JSON-RPC transaction payload.')),
405
+ })
406
+ .check(z.describe('Metadata for this transaction webhook event.')),
407
+ })
408
+ .check(z.describe('A transaction included in a block.'));
409
409
  /**
410
410
  * A `log:emitted` event payload: the raw contract event log. When a
411
411
  * `signature` filter was supplied and decoding succeeds, the best-effort
@@ -461,7 +461,7 @@ export var schema;
461
461
  .check(z.int(), z.nonnegative(), z.describe('Index of the transaction within its block.'), z.meta({ examples: [0] })),
462
462
  })
463
463
  .check(z.describe('Payload for a `log:emitted` event (a raw contract event log).'));
464
- /** Payload for a `block:created` event (a TIDX-derived block header). */
464
+ /** Payload for a `block:created` event (an RPC-derived block header). */
465
465
  schema.BlockEvent = z
466
466
  .object({
467
467
  number: z
@@ -1317,6 +1317,50 @@ export function webhooks(options = {}) {
1317
1317
  */
1318
1318
  const endOfBlockIndex = 2_147_483_647;
1319
1319
  const scanPageSize = 100;
1320
+ /** Concurrent per-subscription scans and cursor writes per poll round; caps RPC and DB fan-out. */
1321
+ const pollConcurrency = 8;
1322
+ /** Event types scanned together when their combined state fits one existing scan round. */
1323
+ const pollEventTypeConcurrency = 2;
1324
+ /** Maximum subscription states retained by concurrent event-type scan batches. */
1325
+ const pollStateLimit = WebhookTransfer.scanCohortSize * pollConcurrency;
1326
+ /** Schedules asynchronous work through one shared concurrency bound. */
1327
+ function createLimiter(limit) {
1328
+ let active = 0;
1329
+ const waiting = [];
1330
+ return async (fn) => {
1331
+ if (active >= limit)
1332
+ await new Promise((resolve) => waiting.push(resolve));
1333
+ else
1334
+ active++;
1335
+ try {
1336
+ return await fn();
1337
+ }
1338
+ finally {
1339
+ const next = waiting.shift();
1340
+ if (next)
1341
+ next();
1342
+ else
1343
+ active--;
1344
+ }
1345
+ };
1346
+ }
1347
+ /** Runs `fn` over `items` with at most `limit` in flight, preserving order. */
1348
+ async function mapWithLimit(items, limit, fn) {
1349
+ const results = [];
1350
+ let next = 0;
1351
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
1352
+ while (next < items.length) {
1353
+ const index = next++;
1354
+ try {
1355
+ results[index] = { status: 'fulfilled', value: await fn(items[index]) };
1356
+ }
1357
+ catch (reason) {
1358
+ results[index] = { reason, status: 'rejected' };
1359
+ }
1360
+ }
1361
+ }));
1362
+ return results;
1363
+ }
1320
1364
  /** Returns `candidate` only when it is lexicographically after `current`. */
1321
1365
  function laterCursor(current, candidate) {
1322
1366
  const before = Cursor.decode(current, ['int', 'int']);
@@ -1336,13 +1380,9 @@ function laterCursor(current, candidate) {
1336
1380
  * matching row, advancing each active subscription's `(block, index)` keyset
1337
1381
  * cursor.
1338
1382
  *
1339
- * The new monitoring event types (`log:emitted`, `block:created`,
1340
- * `transaction:included`) scan a **narrow confirmed window** `[cursor, head -
1341
- * confirmations]` directly over JSON-RPC (`eth_getLogs`, `eth_getBlockByNumber`,
1342
- * `eth_getBlockReceipts`) — the same data sources the indexer itself consumes —
1343
- * so they need no indexer and stay fresh. The pre-existing `token:transfer`
1344
- * type continues to read its humanized shape from TIDX, clamped to the indexed
1345
- * head so it never runs ahead of the indexer.
1383
+ * Every event type scans a **narrow confirmed window** `[cursor, head -
1384
+ * confirmations]` directly over JSON-RPC (`eth_getLogs`,
1385
+ * `eth_getBlockByNumber`) so webhook delivery does not wait for an indexer.
1346
1386
  *
1347
1387
  * The chain head is injectable (tests pin a fixed historical window); only
1348
1388
  * blocks at least `confirmations` behind head are emitted, giving at-least-once,
@@ -1355,28 +1395,45 @@ function laterCursor(current, candidate) {
1355
1395
  * after the remaining subscriptions run.
1356
1396
  */
1357
1397
  export async function poll(options) {
1358
- const { batchSize = 200, cache: cache_metadata = Store.memory(), chainId, client, confirmations = 2, db, eventTypes = ['token:transfer', 'transaction:included', 'log:emitted', 'block:created'], maxBlocks = 1_000, tidx, } = options;
1398
+ const { batchSize = 200, cache: cache_metadata = Store.memory(), chainId, client, confirmations = 1, db, eventTypes: eventTypes_input = [
1399
+ 'token:transfer',
1400
+ 'transaction:included',
1401
+ 'log:emitted',
1402
+ 'block:created',
1403
+ ], maxBlocks = 1_000, } = options;
1359
1404
  const now = options.now ?? (() => new Date());
1405
+ options.signal?.throwIfAborted();
1360
1406
  // Default (self-host) transport: deliver inline and record the outcome so a
1361
1407
  // sustained-failure subscription auto-disables. On Cloudflare, callers pass a
1362
1408
  // Queue producer instead and the consumer runs `deliverAndRecord`.
1363
1409
  const dispatch = options.dispatch ??
1364
1410
  (({ envelope, subscription }) => Webhooks.deliverAndRecord(db, subscription, envelope));
1365
- // Head resolution is lazy and per-source. RPC types work back from the live
1366
- // RPC head; the legacy TIDX types from the indexed head (never ahead of the
1367
- // indexer). A pinned `headBlockNumber` (tests) overrides both.
1411
+ const dispatchBatch = options.dispatchBatch;
1412
+ // A pinned `headBlockNumber` lets a head subscription supply the exact live
1413
+ // boundary and keeps historical tests deterministic.
1368
1414
  const override = options.headBlockNumber;
1369
1415
  let rpcHead = override === undefined ? undefined : Number(override);
1370
1416
  const resolveRpcHead = async () => (rpcHead ??= await getRpcHead(client));
1371
- let tidxHead = override === undefined ? undefined : Number(override);
1372
- const resolveTidxHead = async () => (tidxHead ??= tidx ? await getIndexedHead(tidx, chainId) : await resolveRpcHead());
1373
- const rpcSafeBlock = Math.max(0, (await resolveRpcHead()) - confirmations);
1374
- // Reclaim expired rows (MPP-owned subscriptions, aged delivery logs) once
1375
- // per tick; reads filter expiry at query time, so this is hygiene only.
1376
- await Webhooks.pruneExpired(db, { now }).catch(() => { });
1377
- // Per-tick lazy cache so multiple subscriptions of the same/related types
1378
- // share fetched blocks and receipts instead of refetching the same window.
1379
- const cache = createChainCache(client);
1417
+ const headStartedAt = Date.now();
1418
+ const rpcSafeBlock = await (async () => {
1419
+ try {
1420
+ const safeBlock = Math.max(0, (await resolveRpcHead()) - confirmations);
1421
+ options.onStage?.({
1422
+ durationMs: Date.now() - headStartedAt,
1423
+ outcome: 'ok',
1424
+ stage: 'rpc_head',
1425
+ });
1426
+ return safeBlock;
1427
+ }
1428
+ catch (error) {
1429
+ options.onStage?.({
1430
+ durationMs: Date.now() - headStartedAt,
1431
+ outcome: 'error',
1432
+ stage: 'rpc_head',
1433
+ });
1434
+ throw error;
1435
+ }
1436
+ })();
1380
1437
  const result = {
1381
1438
  chainId,
1382
1439
  dispatched: 0,
@@ -1385,19 +1442,76 @@ export async function poll(options) {
1385
1442
  scanned: 0,
1386
1443
  subscriptions: 0,
1387
1444
  };
1388
- const scanErrors = [];
1389
- for (const eventType of eventTypes) {
1390
- const legacy = eventType === 'token:transfer';
1391
- const safeBlock = legacy ? Math.max(0, (await resolveTidxHead()) - confirmations) : rpcSafeBlock;
1392
- const subscriptions = await Webhooks.listActive(db, chainId, eventType);
1445
+ let cursorLagBlocks = 0;
1446
+ const pollErrors = [];
1447
+ const eventTypes = [...new Set(eventTypes_input)];
1448
+ const subscriptionsStartedAt = Date.now();
1449
+ const pairs = await Webhooks.listActiveForChain(db, { chainId, eventTypes });
1450
+ options.signal?.throwIfAborted();
1451
+ options.onSubscriptions?.(pairs.length);
1452
+ options.onStage?.({
1453
+ durationMs: Date.now() - subscriptionsStartedAt,
1454
+ outcome: 'ok',
1455
+ stage: 'subscription_read',
1456
+ });
1457
+ const pairsByEventType = Map.groupBy(pairs, (pair) => pair.subscription.eventType);
1458
+ const eventTypes_active = eventTypes.filter((eventType) => (pairsByEventType.get(eventType)?.length ?? 0) > 0);
1459
+ const eventTypeConcurrency = pairs.length <= pollStateLimit
1460
+ ? Math.min(pollEventTypeConcurrency, eventTypes_active.length)
1461
+ : Math.min(1, eventTypes_active.length);
1462
+ const scanRoundSize = Math.floor(pollStateLimit / Math.max(1, eventTypeConcurrency));
1463
+ const scheduleScan = createLimiter(pollConcurrency);
1464
+ const eventTypeResults = await mapWithLimit(eventTypes_active, eventTypeConcurrency, async (eventType) => {
1465
+ options.signal?.throwIfAborted();
1466
+ const safeBlock = rpcSafeBlock;
1467
+ const pairs = pairsByEventType.get(eventType) ?? [];
1468
+ const durations = {
1469
+ cursor_write: 0,
1470
+ enrich: 0,
1471
+ scan: 0,
1472
+ };
1473
+ const observed = new Set();
1474
+ let cursorWriteOutcome = 'ok';
1475
+ let scanOutcome = 'ok';
1476
+ const writeCursor = async (parameters) => {
1477
+ const startedAt = Date.now();
1478
+ observed.add('cursor_write');
1479
+ try {
1480
+ return await Webhooks.compareAndSetCursor(db, parameters);
1481
+ }
1482
+ catch (error) {
1483
+ cursorWriteOutcome = 'error';
1484
+ pollErrors.push(error);
1485
+ return false;
1486
+ }
1487
+ finally {
1488
+ durations.cursor_write += Date.now() - startedAt;
1489
+ }
1490
+ };
1393
1491
  let states = [];
1394
- for (const subscription of subscriptions) {
1492
+ for (const pair of pairs) {
1493
+ const { subscription } = pair;
1395
1494
  result.subscriptions++;
1396
- let cursor = await Webhooks.getCursor(db, subscription.id);
1495
+ let filters;
1496
+ try {
1497
+ filters = parseFilters(subscription.eventType, subscription.filters);
1498
+ }
1499
+ catch (error) {
1500
+ if (!(error instanceof Webhooks.InvalidFilterError))
1501
+ throw error;
1502
+ options.signal?.throwIfAborted();
1503
+ // A breaking filter-schema removal must not poison every chain poll.
1504
+ // Disable the legacy subscription without widening its match set.
1505
+ await Webhooks.updateSubscription(db, subscription.owner, subscription.id, {
1506
+ status: 'disabled',
1507
+ });
1508
+ continue;
1509
+ }
1510
+ let cursor = pair.cursor;
1397
1511
  if (cursor === null) {
1398
1512
  // Legacy subscriptions without a creation checkpoint anchor at the
1399
1513
  // current confirmed head, avoiding a genesis backfill.
1400
- await Webhooks.compareAndSetCursor(db, {
1514
+ await writeCursor({
1401
1515
  cursor: Cursor.encode([safeBlock, endOfBlockIndex]),
1402
1516
  expectedCursor: null,
1403
1517
  subscriptionId: subscription.id,
@@ -1412,7 +1526,7 @@ export async function poll(options) {
1412
1526
  Math.max(0, checkpoint[0] - confirmations),
1413
1527
  endOfBlockIndex,
1414
1528
  ]);
1415
- const committed = await Webhooks.compareAndSetCursor(db, {
1529
+ const committed = await writeCursor({
1416
1530
  cursor: confirmedCursor,
1417
1531
  expectedCursor: cursor,
1418
1532
  subscriptionId: subscription.id,
@@ -1421,9 +1535,15 @@ export async function poll(options) {
1421
1535
  continue;
1422
1536
  cursor = confirmedCursor;
1423
1537
  }
1424
- const window = legacy ? undefined : scanWindow(cursor, safeBlock, maxBlocks);
1538
+ const position = Cursor.decode(cursor, ['int', 'int']);
1539
+ if (position)
1540
+ cursorLagBlocks = Math.max(cursorLagBlocks, safeBlock - position[0]);
1541
+ const window = scanWindow(cursor, safeBlock, maxBlocks);
1542
+ if (window && window.to < safeBlock)
1543
+ result.hasMore = true;
1425
1544
  states.push({
1426
1545
  cursor,
1546
+ filters,
1427
1547
  remaining: batchSize,
1428
1548
  safeBlock: window?.to ?? safeBlock,
1429
1549
  subscription,
@@ -1432,87 +1552,219 @@ export async function poll(options) {
1432
1552
  // Every subscription gets one bounded page before any subscription gets a
1433
1553
  // continuation page, preserving fairness while allowing catch-up.
1434
1554
  while (states.length > 0) {
1555
+ options.signal?.throwIfAborted();
1435
1556
  const continuations = [];
1436
- for (const state of states) {
1437
- const { subscription } = state;
1438
- let page;
1439
- try {
1440
- page = await scanSubscription({
1441
- batchSize: Math.min(scanPageSize, state.remaining),
1442
- cache,
1443
- client,
1444
- cursor: state.cursor,
1445
- maxBlocks,
1446
- safeBlock: state.safeBlock,
1447
- subscription,
1448
- tidx,
1449
- });
1557
+ const round = states;
1558
+ for (let offset = 0; offset < round.length; offset += scanRoundSize) {
1559
+ const batch = round.slice(offset, offset + scanRoundSize);
1560
+ const windows = batch.flatMap((state) => {
1561
+ const window = scanWindow(state.cursor, state.safeBlock, maxBlocks);
1562
+ return window ? [window] : [];
1563
+ });
1564
+ const maxWindowBlocks = Math.max(0, ...windows.map((window) => window.to - window.from + 1));
1565
+ const totalWindowBlocks = windows.reduce((total, window) => total + window.to - window.from + 1, 0);
1566
+ options.onScanCheckpoint?.({
1567
+ concurrency: pollConcurrency,
1568
+ eventType,
1569
+ maxWindowBlocks,
1570
+ phase: 'start',
1571
+ rpcMethod: scanRpcMethod(eventType),
1572
+ subscriptionLimit: scanRoundSize,
1573
+ subscriptionCount: batch.length,
1574
+ totalWindowBlocks,
1575
+ });
1576
+ const scanStartedAt = Date.now();
1577
+ const pages = await scanSubscriptions({
1578
+ client,
1579
+ eventType,
1580
+ maxBlocks,
1581
+ schedule: scheduleScan,
1582
+ states: batch,
1583
+ });
1584
+ options.onScanCheckpoint?.({
1585
+ concurrency: pollConcurrency,
1586
+ durationMs: Date.now() - scanStartedAt,
1587
+ eventType,
1588
+ failedSubscriptions: pages.filter((page) => page.status === 'rejected').length,
1589
+ matchedItems: pages.reduce((total, page) => total + (page.status === 'fulfilled' ? page.value.items.length : 0), 0),
1590
+ maxWindowBlocks,
1591
+ outcome: scanResultOutcome(pages),
1592
+ phase: 'complete',
1593
+ rpcMethod: scanRpcMethod(eventType),
1594
+ subscriptionLimit: scanRoundSize,
1595
+ subscriptionCount: batch.length,
1596
+ totalWindowBlocks,
1597
+ });
1598
+ options.signal?.throwIfAborted();
1599
+ durations.scan += Date.now() - scanStartedAt;
1600
+ observed.add('scan');
1601
+ if (pages.some((page) => page.status === 'rejected'))
1602
+ scanOutcome = 'error';
1603
+ const busy = [];
1604
+ const idle = [];
1605
+ for (let i = 0; i < batch.length; i++) {
1606
+ const state = batch[i];
1607
+ const settled = pages[i];
1608
+ if (settled.status === 'rejected') {
1609
+ pollErrors.push(settled.reason);
1610
+ continue;
1611
+ }
1612
+ result.scanned += settled.value.items.length;
1613
+ state.remaining -= settled.value.items.length;
1614
+ (settled.value.items.length > 0 ? busy : idle).push({ page: settled.value, state });
1450
1615
  }
1451
- catch (error) {
1452
- scanErrors.push(error);
1453
- continue;
1616
+ // Empty pages only advance their cursor: no dispatch order to preserve,
1617
+ // so one statement commits the cohort while retaining per-row CAS semantics.
1618
+ const cursorWritesStartedAt = Date.now();
1619
+ const candidates = idle.flatMap(({ page, state }) => {
1620
+ options.signal?.throwIfAborted();
1621
+ const candidate = page.scannedThrough !== undefined
1622
+ ? Cursor.encode([page.scannedThrough, endOfBlockIndex])
1623
+ : page.hasMore
1624
+ ? undefined
1625
+ : Cursor.encode([state.safeBlock, endOfBlockIndex]);
1626
+ const cursor = candidate ? laterCursor(state.cursor, candidate) : state.cursor;
1627
+ return cursor === state.cursor ? [] : [{ cursor, state }];
1628
+ });
1629
+ if (candidates.length > 0) {
1630
+ const committed = await (async () => {
1631
+ try {
1632
+ return await Webhooks.compareAndSetCursors(db, {
1633
+ cursors: candidates.map(({ cursor, state }) => ({
1634
+ cursor,
1635
+ expectedCursor: state.cursor,
1636
+ subscriptionId: state.subscription.id,
1637
+ })),
1638
+ });
1639
+ }
1640
+ catch (error) {
1641
+ cursorWriteOutcome = 'error';
1642
+ pollErrors.push(error);
1643
+ return new Set();
1644
+ }
1645
+ })();
1646
+ for (const { cursor, state } of candidates)
1647
+ if (committed.has(state.subscription.id))
1648
+ state.cursor = cursor;
1649
+ durations.cursor_write += Date.now() - cursorWritesStartedAt;
1650
+ observed.add('cursor_write');
1454
1651
  }
1455
- result.scanned += page.items.length;
1456
- state.remaining -= page.items.length;
1457
- let accepted;
1458
- let failed = false;
1459
- for (const item of page.items) {
1460
- // Token metadata is best-effort for webhooks: transfers use it for display,
1461
- // while transactions expose fee-token references without blocking delivery.
1462
- const data = eventType === 'token:transfer'
1463
- ? await withTokenMetadata(item.data, { cache: cache_metadata, chainId, client })
1464
- : eventType === 'transaction:included'
1465
- ? await withFeeTokenMetadata(item.data, { cache: cache_metadata, chainId, client })
1466
- : item.data;
1467
- const envelope = Webhooks.buildEnvelope({
1468
- blockNumber: item.cursor[0],
1469
- createdAt: now(),
1470
- data,
1471
- logIndex: item.cursor[1],
1472
- subscription,
1473
- });
1474
- const delivered = await dispatch({ envelope, subscription });
1475
- if (!delivered.ok) {
1476
- result.failed++;
1477
- failed = true;
1478
- break;
1652
+ for (const { page, state } of busy) {
1653
+ options.signal?.throwIfAborted();
1654
+ const { subscription } = state;
1655
+ let accepted;
1656
+ let failed = false;
1657
+ const buildDispatchable = async (item) => {
1658
+ options.signal?.throwIfAborted();
1659
+ // Token metadata is best-effort for webhooks: transfers use it for display,
1660
+ // while transactions expose fee-token references without blocking delivery.
1661
+ const enrichStartedAt = Date.now();
1662
+ const data = eventType === 'token:transfer'
1663
+ ? await withTokenMetadata(item.data, { cache: cache_metadata, chainId, client })
1664
+ : eventType === 'transaction:included'
1665
+ ? await withFeeTokenMetadata(item.data, {
1666
+ cache: cache_metadata,
1667
+ chainId,
1668
+ client,
1669
+ })
1670
+ : item.data;
1671
+ durations.enrich += Date.now() - enrichStartedAt;
1672
+ observed.add('enrich');
1673
+ options.signal?.throwIfAborted();
1674
+ const envelope = Webhooks.buildEnvelope({
1675
+ blockNumber: item.cursor[0],
1676
+ createdAt: now(),
1677
+ data,
1678
+ logIndex: item.cursor[1],
1679
+ subscription,
1680
+ });
1681
+ return { envelope, subscription };
1682
+ };
1683
+ if (dispatchBatch) {
1684
+ const dispatchables = [];
1685
+ for (const item of page.items)
1686
+ dispatchables.push(await buildDispatchable(item));
1687
+ options.signal?.throwIfAborted();
1688
+ const delivered = await dispatchBatch(dispatchables);
1689
+ if (delivered.ok) {
1690
+ accepted = page.items.at(-1)?.cursor;
1691
+ result.dispatched += dispatchables.length;
1692
+ }
1693
+ else {
1694
+ result.failed += dispatchables.length;
1695
+ failed = true;
1696
+ }
1697
+ }
1698
+ else {
1699
+ for (const item of page.items) {
1700
+ const dispatchable = await buildDispatchable(item);
1701
+ const delivered = await dispatch(dispatchable);
1702
+ if (!delivered.ok) {
1703
+ result.failed++;
1704
+ failed = true;
1705
+ break;
1706
+ }
1707
+ accepted = item.cursor;
1708
+ result.dispatched++;
1709
+ }
1710
+ }
1711
+ // Persist every accepted page before another subscription begins its
1712
+ // continuation, so a later scan failure retains completed work.
1713
+ const candidate = (() => {
1714
+ if (failed)
1715
+ return accepted ? Cursor.encode([...accepted]) : undefined;
1716
+ if (page.scannedThrough !== undefined)
1717
+ return Cursor.encode([page.scannedThrough, endOfBlockIndex]);
1718
+ if (accepted)
1719
+ return Cursor.encode([...accepted]);
1720
+ if (!page.hasMore)
1721
+ return Cursor.encode([state.safeBlock, endOfBlockIndex]);
1722
+ return undefined;
1723
+ })();
1724
+ const cursor = candidate ? laterCursor(state.cursor, candidate) : state.cursor;
1725
+ const progressed = cursor !== state.cursor;
1726
+ let committed = false;
1727
+ if (progressed) {
1728
+ committed = await writeCursor({
1729
+ cursor,
1730
+ expectedCursor: state.cursor,
1731
+ subscriptionId: subscription.id,
1732
+ });
1733
+ if (committed)
1734
+ state.cursor = cursor;
1735
+ }
1736
+ if (!failed && page.hasMore === true) {
1737
+ if (committed && state.remaining > 0)
1738
+ continuations.push(state);
1739
+ else
1740
+ result.hasMore = true;
1479
1741
  }
1480
- accepted = item.cursor;
1481
- result.dispatched++;
1482
- }
1483
- // Persist every accepted page before another subscription begins its
1484
- // continuation, so a later scan failure retains completed work.
1485
- const candidate = (() => {
1486
- if (failed)
1487
- return accepted ? Cursor.encode([...accepted]) : undefined;
1488
- if (page.scannedThrough !== undefined)
1489
- return Cursor.encode([page.scannedThrough, endOfBlockIndex]);
1490
- if (accepted)
1491
- return Cursor.encode([...accepted]);
1492
- if (!page.hasMore)
1493
- return Cursor.encode([state.safeBlock, endOfBlockIndex]);
1494
- return undefined;
1495
- })();
1496
- const cursor = candidate ? laterCursor(state.cursor, candidate) : state.cursor;
1497
- const progressed = cursor !== state.cursor;
1498
- let committed = false;
1499
- if (progressed) {
1500
- committed = await Webhooks.compareAndSetCursor(db, {
1501
- cursor,
1502
- expectedCursor: state.cursor,
1503
- subscriptionId: subscription.id,
1504
- });
1505
- if (committed)
1506
- state.cursor = cursor;
1507
1742
  }
1508
- if (!failed && page.hasMore === true && committed && state.remaining > 0)
1509
- continuations.push(state);
1510
1743
  }
1511
1744
  states = continuations;
1512
1745
  }
1513
- }
1514
- if (scanErrors.length > 0)
1515
- throw scanErrors[0];
1746
+ for (const stage of observed) {
1747
+ const outcome = (() => {
1748
+ if (stage === 'cursor_write')
1749
+ return cursorWriteOutcome;
1750
+ if (stage === 'scan')
1751
+ return scanOutcome;
1752
+ return 'ok';
1753
+ })();
1754
+ options.onStage?.({
1755
+ durationMs: durations[stage],
1756
+ eventType,
1757
+ outcome,
1758
+ stage,
1759
+ });
1760
+ }
1761
+ });
1762
+ for (const result of eventTypeResults)
1763
+ if (result.status === 'rejected')
1764
+ pollErrors.push(result.reason);
1765
+ options.onCursorLag?.(cursorLagBlocks);
1766
+ if (pollErrors.length > 0)
1767
+ throw pollErrors[0];
1516
1768
  return result;
1517
1769
  }
1518
1770
  /**
@@ -1583,103 +1835,77 @@ function withResolvedFeeToken(data, metadata) {
1583
1835
  result['feeToken'] = { address, ...token };
1584
1836
  return result;
1585
1837
  }
1586
- /** Adds best-effort fee-token references to a transaction and its embedded receipt. */
1838
+ /** Adds a best-effort fee-token reference to a transaction. */
1587
1839
  async function withFeeTokenMetadata(data, deps) {
1588
1840
  if (typeof data !== 'object' || data === null)
1589
1841
  return data;
1590
1842
  const transaction = data;
1591
- const meta = transaction['meta'];
1592
- const receipt = typeof meta === 'object' && meta !== null
1593
- ? meta['receipt']
1594
- : undefined;
1595
- const addresses = new Set();
1596
- const transactionAddress = feeTokenAddress(transaction['feeToken']);
1597
- if (transactionAddress)
1598
- addresses.add(transactionAddress.toLowerCase());
1599
- if (typeof receipt === 'object' && receipt !== null) {
1600
- const receiptAddress = feeTokenAddress(receipt['feeToken']);
1601
- if (receiptAddress)
1602
- addresses.add(receiptAddress.toLowerCase());
1603
- }
1604
- if (addresses.size === 0)
1843
+ const address = feeTokenAddress(transaction['feeToken']);
1844
+ if (!address)
1605
1845
  return data;
1606
- const resolved = await Promise.all([...addresses].map(async (address) => [address, await resolveTokenMetadata(address, deps)]));
1607
- const metadata = new Map(resolved.filter((entry) => entry[1] !== undefined));
1608
- const result = withResolvedFeeToken(transaction, metadata);
1609
- if (typeof receipt !== 'object' || receipt === null)
1610
- return result;
1611
- return {
1612
- ...result,
1613
- meta: {
1614
- ...meta,
1615
- receipt: withResolvedFeeToken(receipt, metadata),
1616
- },
1617
- };
1846
+ const token = await resolveTokenMetadata(address, deps);
1847
+ const metadata = new Map();
1848
+ if (token)
1849
+ metadata.set(address.toLowerCase(), token);
1850
+ return withResolvedFeeToken(transaction, metadata);
1618
1851
  }
1619
1852
  /** Returns the live RPC head block number for the chain. */
1620
1853
  export async function getRpcHead(client) {
1621
1854
  return Number(await client.getBlockNumber());
1622
1855
  }
1623
- /**
1624
- * Returns the highest block number TIDX has indexed for a chain (0 when none).
1625
- * The legacy `token:transfer` type works back from this so it never scans past
1626
- * the indexer.
1627
- */
1628
- export async function getIndexedHead(tidx, chainId) {
1629
- const result = await tidx.fetch({
1630
- chainId,
1631
- query: 'SELECT num FROM blocks ORDER BY num DESC LIMIT 1',
1856
+ /** Groups aligned cursors only when their bounded upstream scans are equivalent. */
1857
+ async function scanSubscriptions(options) {
1858
+ const { client, eventType, maxBlocks, schedule, states } = options;
1859
+ if (eventType !== 'token:transfer')
1860
+ return scanIndependent({ client, eventType, maxBlocks, schedule, states });
1861
+ return WebhookTransfer.scan({
1862
+ client,
1863
+ concurrency: pollConcurrency,
1864
+ maxBlocks,
1865
+ schedule,
1866
+ states: states.map((state) => ({
1867
+ filters: state.filters,
1868
+ limit: Math.min(scanPageSize, state.remaining),
1869
+ window: scanWindow(state.cursor, state.safeBlock, maxBlocks),
1870
+ })),
1632
1871
  });
1633
- return Value.toNumber(result.rows[0]?.['num']) ?? 0;
1634
1872
  }
1635
- /**
1636
- * Scans new events for one subscription. The RPC monitoring types
1637
- * (`log:emitted` / `block:created` / `transaction:included`) read a confirmed
1638
- * window directly over JSON-RPC; the legacy `token:transfer` type dispatches to
1639
- * its TIDX read core (`Transfers.scan`).
1640
- */
1641
- async function scanSubscription(options) {
1642
- const { batchSize, cache, client, cursor, maxBlocks, safeBlock, subscription, tidx } = options;
1643
- // Stored `chainId` was validated against `Schema.ChainId` at create time.
1644
- const chainId = subscription.chainId;
1645
- if (subscription.eventType === 'log:emitted')
1646
- return scanLogs({
1647
- batchSize,
1648
- cache,
1649
- client,
1650
- cursor,
1651
- filters: parseFilters('log:emitted', subscription.filters),
1652
- maxBlocks,
1653
- safeBlock,
1654
- });
1655
- if (subscription.eventType === 'block:created')
1656
- return scanBlocks({
1657
- batchSize,
1658
- cache,
1659
- cursor,
1660
- filters: parseFilters('block:created', subscription.filters),
1661
- maxBlocks,
1662
- safeBlock,
1663
- });
1664
- if (subscription.eventType === 'transaction:included')
1665
- return scanTxs({
1666
- batchSize,
1873
+ /** Returns the bounded JSON-RPC method used by an event scanner. */
1874
+ function scanRpcMethod(eventType) {
1875
+ return eventType === 'block:created' || eventType === 'transaction:included'
1876
+ ? 'eth_getBlockByNumber'
1877
+ : 'eth_getLogs';
1878
+ }
1879
+ /** Classifies a completed batch without exposing an unbounded upstream error. */
1880
+ function scanResultOutcome(pages) {
1881
+ const rejected = pages.filter((page) => page.status === 'rejected');
1882
+ if (rejected.some((page) => page.reason instanceof BlockUnavailableError))
1883
+ return 'missing';
1884
+ if (rejected.length > 0)
1885
+ return 'error';
1886
+ return pages.every((page) => page.status === 'fulfilled' && page.value.items.length === 0)
1887
+ ? 'empty'
1888
+ : 'ok';
1889
+ }
1890
+ /** Scans CPU-matched streams per subscription while sharing concurrent block reads. */
1891
+ function scanIndependent(options) {
1892
+ const { client, eventType, maxBlocks, schedule, states } = options;
1893
+ const cache = createChainCache(client);
1894
+ return mapWithLimit(states, pollConcurrency, (state) => schedule(() => {
1895
+ const shared = {
1896
+ batchSize: Math.min(scanPageSize, state.remaining),
1667
1897
  cache,
1668
- cursor,
1669
- filters: parseFilters('transaction:included', subscription.filters),
1898
+ cursor: state.cursor,
1899
+ filters: state.filters,
1670
1900
  maxBlocks,
1671
- safeBlock,
1672
- });
1673
- // Legacy TIDX-backed type.
1674
- if (!tidx)
1675
- return { hasMore: false, items: [] };
1676
- const filters = transferFilters(subscription.filters);
1677
- const page = await Transfers.scan({ tidx }, { chainId, cursor, limit: batchSize, order: 'asc', toBlock: safeBlock, ...filters });
1678
- // A cap-truncated fallback sample is an arbitrary subset: dispatching it
1679
- // would advance the cursor past unseen matches. Hold position and retry.
1680
- if (page.capped)
1681
- return { hasMore: true, items: [] };
1682
- return { hasMore: page.hasMore, items: page.items };
1901
+ safeBlock: state.safeBlock,
1902
+ };
1903
+ if (eventType === 'block:created')
1904
+ return scanBlocks(shared);
1905
+ if (eventType === 'log:emitted')
1906
+ return scanLogs({ ...shared, client });
1907
+ return scanTxs(shared);
1908
+ }));
1683
1909
  }
1684
1910
  const filterSchemas = {
1685
1911
  'block:created': schema.BlockFilters,
@@ -1699,17 +1925,6 @@ export function parseFilters(eventType, filters) {
1699
1925
  throw new Webhooks.InvalidFilterError(eventType, Response.validationDetails(result.error.issues));
1700
1926
  return result.data;
1701
1927
  }
1702
- /** Narrows stored `transfer` filters into validated `Transfers.scan` predicates. */
1703
- function transferFilters(filters) {
1704
- const parsed = schema.TransferFilters.safeParse(filters);
1705
- const { address, recipient, sender, token } = parsed.success ? parsed.data : {};
1706
- return {
1707
- ...(address === undefined ? {} : { eitherSide: address }),
1708
- ...(recipient === undefined ? {} : { recipient }),
1709
- ...(sender === undefined ? {} : { sender }),
1710
- ...(token === undefined ? {} : { token }),
1711
- };
1712
- }
1713
1928
  /** Recursively lowercases string leaves of a filter operator (for address args). */
1714
1929
  function lowercaseLeaves(op) {
1715
1930
  if (typeof op === 'string')
@@ -1842,52 +2057,34 @@ function scanWindow(cursor, safeBlock, maxBlocks) {
1842
2057
  function atOrBefore(block, index, skip) {
1843
2058
  return block < skip[0] || (block === skip[0] && index <= skip[1]);
1844
2059
  }
1845
- /** Builds a per-tick {@link ChainCache} bound to a viem client. */
2060
+ /** Builds a per-batch {@link ChainCache} bound to a viem client. */
1846
2061
  function createChainCache(client) {
1847
2062
  const headers = new Map();
1848
2063
  const blocks = new Map();
1849
- const receipts = new Map();
2064
+ const singleFlight = (pending, key, load) => {
2065
+ const existing = pending.get(key);
2066
+ if (existing)
2067
+ return existing;
2068
+ const promise = load().finally(() => pending.delete(key));
2069
+ pending.set(key, promise);
2070
+ return promise;
2071
+ };
1850
2072
  return {
1851
2073
  block(n) {
1852
- let p = blocks.get(n);
1853
- if (!p) {
1854
- // The transaction payload reuses `humanizeTransaction`, which consumes
1855
- // the raw RPC tx shape, so the full block stays on the untyped path.
1856
- p = rpc(client, 'eth_getBlockByNumber', [
1857
- blockTag(n),
1858
- true,
1859
- ]).then((block) => {
1860
- if (!block)
1861
- throw new BlockUnavailableError(n);
1862
- return block;
1863
- });
1864
- blocks.set(n, p);
1865
- }
1866
- return p;
2074
+ return singleFlight(blocks, n, () =>
2075
+ // The transaction payload reuses `humanizeTransaction`, which consumes
2076
+ // the raw RPC tx shape, so the full block stays on the untyped path.
2077
+ rpc(client, 'eth_getBlockByNumber', [
2078
+ blockTag(n),
2079
+ true,
2080
+ ]).then((block) => {
2081
+ if (!block)
2082
+ throw new BlockUnavailableError(n);
2083
+ return block;
2084
+ }));
1867
2085
  },
1868
2086
  header(n) {
1869
- let p = headers.get(n);
1870
- if (!p) {
1871
- p = client.getBlock({ blockNumber: BigInt(n), includeTransactions: false });
1872
- headers.set(n, p);
1873
- }
1874
- return p;
1875
- },
1876
- receipts(n) {
1877
- let p = receipts.get(n);
1878
- if (!p) {
1879
- p = rpc(client, 'eth_getBlockReceipts', [blockTag(n)]).then((list) => {
1880
- const map = new Map();
1881
- for (const receipt of list ?? []) {
1882
- const hash = receipt['transactionHash'];
1883
- if (typeof hash === 'string')
1884
- map.set(hash.toLowerCase(), receipt);
1885
- }
1886
- return map;
1887
- });
1888
- receipts.set(n, p);
1889
- }
1890
- return p;
2087
+ return singleFlight(headers, n, () => client.getBlock({ blockNumber: BigInt(n), includeTransactions: false }));
1891
2088
  },
1892
2089
  };
1893
2090
  }
@@ -2005,7 +2202,7 @@ function pushTopics(f, event) {
2005
2202
  pushHex(f.topic2),
2006
2203
  pushHex(f.topic3),
2007
2204
  ];
2008
- while (topics.at(-1) === undefined)
2205
+ while (topics.length > 0 && topics.at(-1) === undefined)
2009
2206
  topics.pop();
2010
2207
  return topics.map((topic) => topic ?? null);
2011
2208
  }
@@ -2040,17 +2237,7 @@ function decodeScanLog(log, event) {
2040
2237
  return log;
2041
2238
  }
2042
2239
  }
2043
- /**
2044
- * Scans contract event logs over the confirmed window via `eth_getLogs`,
2045
- * then re-checks **every** filter in JS. Operators the RPC cannot express
2046
- * (`not`, ranges, non-indexed `args`) still apply. Two modes:
2047
- * - **raw** (no `signature`): `topic0..3` are matched directly.
2048
- * - **decoded** (`signature` present): the selector, raw topics, and supported
2049
- * indexed `args` are pushed down before the JS pass matches decoded values.
2050
- *
2051
- * Exact and membership address/topic operators are pushed down to the node;
2052
- * all operators are re-checked in JS over the narrow window.
2053
- */
2240
+ /** Scans contract logs for one subscription, then re-checks every filter in memory. */
2054
2241
  async function scanLogs(options) {
2055
2242
  const { batchSize, cache, client, cursor, filters, maxBlocks, safeBlock } = options;
2056
2243
  const f = filters;
@@ -2063,8 +2250,6 @@ async function scanLogs(options) {
2063
2250
  { message: 'Unsupported event signature.', path: ['signature'] },
2064
2251
  ]);
2065
2252
  const address = pushAddress(f.address);
2066
- const fromBlock = BigInt(window.from);
2067
- const toBlock = BigInt(window.to);
2068
2253
  const topics = pushTopics(f, event);
2069
2254
  const hasRawTopics = topics.slice(1).some((topic) => topic !== null);
2070
2255
  const args = event ? pushEventArgs(f.args, event) : {};
@@ -2083,16 +2268,13 @@ async function scanLogs(options) {
2083
2268
  ...(address ? { address } : {}),
2084
2269
  ...(Object.keys(args).length > 0 ? { args: args } : {}),
2085
2270
  event,
2086
- fromBlock,
2271
+ fromBlock: BigInt(window.from),
2087
2272
  strict: false,
2088
- toBlock,
2273
+ toBlock: BigInt(window.to),
2089
2274
  }));
2090
2275
  logs.sort((a, b) => {
2091
- const ab = Number(a.blockNumber ?? 0n);
2092
- const bb = Number(b.blockNumber ?? 0n);
2093
- if (ab !== bb)
2094
- return ab - bb;
2095
- return (a.logIndex ?? 0) - (b.logIndex ?? 0);
2276
+ const block = Number(a.blockNumber ?? 0n) - Number(b.blockNumber ?? 0n);
2277
+ return block || (a.logIndex ?? 0) - (b.logIndex ?? 0);
2096
2278
  });
2097
2279
  const items = [];
2098
2280
  for (const log of logs) {
@@ -2104,9 +2286,7 @@ async function scanLogs(options) {
2104
2286
  continue;
2105
2287
  if (!matchesLog(log, f, event))
2106
2288
  continue;
2107
- const header = await cache.header(blockNumber);
2108
- const timestamp = blockIso(header);
2109
- const data = toLogEvent(log, timestamp, f.signature, event);
2289
+ const data = toLogEvent(log, blockIso(await cache.header(blockNumber)), f.signature, event);
2110
2290
  if (data) {
2111
2291
  items.push({ cursor: [data.blockNumber, data.logIndex], data });
2112
2292
  if (items.length >= batchSize)
@@ -2118,6 +2298,11 @@ async function scanLogs(options) {
2118
2298
  /** Re-checks all log filters (including non-pushable operators) in JS. */
2119
2299
  function matchesLog(log, f, event) {
2120
2300
  const topics = log.topics;
2301
+ if (event) {
2302
+ const selector = encodeEventTopics({ abi: [event] })[0];
2303
+ if (topics[0]?.toLowerCase() !== selector.toLowerCase())
2304
+ return false;
2305
+ }
2121
2306
  if (f.address !== undefined && !matchesOperator(log.address, f.address))
2122
2307
  return false;
2123
2308
  if (f.blockNumber !== undefined &&
@@ -2189,11 +2374,7 @@ function toBlockEvent(block) {
2189
2374
  transactionCount,
2190
2375
  };
2191
2376
  }
2192
- /**
2193
- * Scans new blocks over the confirmed window via viem's `getBlock`. One row per
2194
- * block (the keyset index is always 0); filters match block-level metadata. An
2195
- * empty filter is a per-block heartbeat.
2196
- */
2377
+ /** Scans new blocks over one subscription's confirmed window. */
2197
2378
  async function scanBlocks(options) {
2198
2379
  const { batchSize, cache, cursor, filters, maxBlocks, safeBlock } = options;
2199
2380
  const f = filters;
@@ -2201,10 +2382,10 @@ async function scanBlocks(options) {
2201
2382
  if (!window)
2202
2383
  return { items: [], scannedThrough: undefined };
2203
2384
  const items = [];
2204
- for (let n = window.from; n <= window.to; n++) {
2205
- if (atOrBefore(n, 0, window.skip))
2385
+ for (let blockNumber = window.from; blockNumber <= window.to; blockNumber++) {
2386
+ if (atOrBefore(blockNumber, 0, window.skip))
2206
2387
  continue;
2207
- const block = await cache.header(n);
2388
+ const block = await cache.header(blockNumber);
2208
2389
  if (!block)
2209
2390
  continue;
2210
2391
  if (!matchesBlock(block, f))
@@ -2237,7 +2418,7 @@ function matchesBlock(block, f) {
2237
2418
  return false;
2238
2419
  return true;
2239
2420
  }
2240
- // transaction:included (eth_getBlockByNumber + eth_getBlockReceipts)
2421
+ // transaction:included (eth_getBlockByNumber)
2241
2422
  /** Maps a `transaction:included` scalar filter field to its raw-tx field + kind. */
2242
2423
  const txFields = {
2243
2424
  blockNumber: { field: 'blockNumber', kind: 'number' },
@@ -2253,13 +2434,7 @@ const txFields = {
2253
2434
  validBefore: { field: 'validBefore', kind: 'number' },
2254
2435
  value: { field: 'value', kind: 'number' },
2255
2436
  };
2256
- /**
2257
- * Scans included transactions over the confirmed window. Each block is fetched
2258
- * with full transactions via `eth_getBlockByNumber(n, true)`; receipts are
2259
- * fetched lazily (only when a `status`/`gasUsed`/`feePayer` filter needs them).
2260
- * The delivered payload starts from `Transactions.humanizeTransaction`; the
2261
- * poller then resolves its fee-token references.
2262
- */
2437
+ /** Scans included transactions over one subscription's confirmed window. */
2263
2438
  async function scanTxs(options) {
2264
2439
  const { batchSize, cache, cursor, filters, maxBlocks, safeBlock } = options;
2265
2440
  const f = filters;
@@ -2267,54 +2442,33 @@ async function scanTxs(options) {
2267
2442
  if (!window)
2268
2443
  return { items: [], scannedThrough: undefined };
2269
2444
  const includeCalls = f.includeCalls ?? true;
2270
- const includeReceipt = f.includeReceipt ?? false;
2271
- // Filter-driven receipt need gates matching; `includeReceipt` only enriches the
2272
- // payload, so it never drops a row when the receipt is missing.
2273
- const needsReceipt = f.status !== undefined || f.gasUsed !== undefined || f.feePayer !== undefined;
2274
2445
  const items = [];
2275
- for (let n = window.from; n <= window.to; n++) {
2276
- const block = await cache.block(n);
2277
- const txs = Array.isArray(block['transactions']) ? block['transactions'] : [];
2278
- if (txs.length === 0)
2279
- continue;
2280
- const receipts = needsReceipt || includeReceipt ? await cache.receipts(n) : undefined;
2446
+ for (let blockNumber = window.from; blockNumber <= window.to; blockNumber++) {
2447
+ const block = await cache.block(blockNumber);
2448
+ const transactions = Array.isArray(block['transactions'])
2449
+ ? block['transactions']
2450
+ : [];
2281
2451
  const timestamp = asString(block['timestamp']);
2282
- for (let i = 0; i < txs.length; i++) {
2283
- const tx = txs[i];
2284
- const index = Value.hexToNumber(tx['transactionIndex']) ?? i;
2285
- if (atOrBefore(n, index, window.skip))
2286
- continue;
2287
- const receipt = receipts?.get(String(tx['hash']).toLowerCase());
2288
- if (needsReceipt && !receipt)
2452
+ for (let offset = 0; offset < transactions.length; offset++) {
2453
+ const transaction = transactions[offset];
2454
+ const transactionIndex = Value.hexToNumber(transaction['transactionIndex']) ?? offset;
2455
+ if (atOrBefore(blockNumber, transactionIndex, window.skip))
2289
2456
  continue;
2290
- if (!matchesTx(tx, f, includeCalls, receipt, timestamp, n))
2457
+ if (!matchesTx(transaction, f, includeCalls, timestamp, blockNumber))
2291
2458
  continue;
2292
- const parsed = Transactions.schema.Rpc.Transaction.safeParse(tx);
2459
+ const parsed = Transactions.schema.Rpc.Transaction.safeParse(transaction);
2293
2460
  if (!parsed.success)
2294
2461
  continue;
2295
- let data = Transactions.humanizeTransaction(parsed.data);
2296
- // Embed the humanized receipt under `meta.receipt`, matching the read
2297
- // endpoint's `include=receipt` shape so `data` stays a valid envelope.
2298
- if (includeReceipt && receipt) {
2299
- const r = Receipts.schema.Rpc.Receipt.safeParse(receipt);
2300
- if (r.success)
2301
- data = {
2302
- ...data,
2303
- meta: {
2304
- ...data['meta'],
2305
- receipt: Receipts.humanizeReceipt(r.data),
2306
- },
2307
- };
2308
- }
2309
- items.push({ cursor: [n, index], data });
2462
+ const data = Transactions.humanizeTransaction(parsed.data);
2463
+ items.push({ cursor: [blockNumber, transactionIndex], data });
2310
2464
  if (items.length >= batchSize)
2311
2465
  return { hasMore: true, items };
2312
2466
  }
2313
2467
  }
2314
2468
  return { items, scannedThrough: window.to };
2315
2469
  }
2316
- /** Re-checks all transaction filters in JS against the raw RPC tx (+ receipt). */
2317
- function matchesTx(tx, f, includeCalls, receipt, timestamp, blockNumber) {
2470
+ /** Re-checks transaction filters against the raw RPC transaction. */
2471
+ function matchesTx(tx, f, includeCalls, timestamp, blockNumber) {
2318
2472
  const from = tx['from'];
2319
2473
  const to = tx['to'];
2320
2474
  const calls = tx['calls'];
@@ -2351,18 +2505,6 @@ function matchesTx(tx, f, includeCalls, receipt, timestamp, blockNumber) {
2351
2505
  }
2352
2506
  if (f.timestamp !== undefined && !matchesOperator(timestamp, f.timestamp, 'number'))
2353
2507
  return false;
2354
- if (receipt) {
2355
- if (f.feePayer !== undefined && !matchesOperator(asString(receipt['feePayer']), f.feePayer))
2356
- return false;
2357
- if (f.gasUsed !== undefined &&
2358
- !matchesOperator(asString(receipt['gasUsed']), f.gasUsed, 'number'))
2359
- return false;
2360
- if (f.status !== undefined) {
2361
- const ok = Value.hexToNumber(receipt['status']) === 1;
2362
- if ((f.status === 'success') !== ok)
2363
- return false;
2364
- }
2365
- }
2366
2508
  return true;
2367
2509
  }
2368
2510
  /** Coerces an RPC field to a string for matching, or `undefined` when absent. */