tapimo 0.10.2 → 0.11.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 (135) hide show
  1. package/dist/App.d.ts +3412 -1968
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/App.js.map +1 -1
  4. package/dist/Webhooks.d.ts +0 -75
  5. package/dist/Webhooks.d.ts.map +1 -1
  6. package/dist/Webhooks.js +1 -143
  7. package/dist/Webhooks.js.map +1 -1
  8. package/dist/admin/App.d.ts +74 -74
  9. package/dist/admin/apps/api-keys.d.ts +4 -4
  10. package/dist/admin/apps/earn-vaults.d.ts +1 -1
  11. package/dist/admin/apps/organizations.d.ts +3 -3
  12. package/dist/admin/apps/verified-tokens.d.ts +74 -74
  13. package/dist/apps/data/App.d.ts +3618 -2954
  14. package/dist/apps/data/App.d.ts.map +1 -1
  15. package/dist/apps/data/App.js +5 -2
  16. package/dist/apps/data/App.js.map +1 -1
  17. package/dist/apps/data/routes/activities.d.ts +1 -1
  18. package/dist/apps/data/routes/activities.d.ts.map +1 -1
  19. package/dist/apps/data/routes/activities.js +55 -27
  20. package/dist/apps/data/routes/activities.js.map +1 -1
  21. package/dist/apps/data/routes/addresses.d.ts +343 -343
  22. package/dist/apps/data/routes/balances.d.ts +11 -11
  23. package/dist/apps/data/routes/blocks.d.ts +5 -5
  24. package/dist/apps/data/routes/coingecko.d.ts +176 -176
  25. package/dist/apps/data/routes/earn.d.ts +4 -4
  26. package/dist/apps/data/routes/exchanges.d.ts +4 -4
  27. package/dist/apps/data/routes/fee-amm.d.ts +26 -26
  28. package/dist/apps/data/routes/indexer.d.ts +34 -34
  29. package/dist/apps/data/routes/receipts.d.ts +236 -236
  30. package/dist/apps/data/routes/tokenlist.d.ts +12 -12
  31. package/dist/apps/data/routes/tokens.d.ts +3 -3
  32. package/dist/apps/data/routes/transactions.d.ts +1 -1
  33. package/dist/apps/data/routes/transfers.d.ts +13 -13
  34. package/dist/apps/data/routes/valuation.d.ts +21 -21
  35. package/dist/apps/data/routes/verified-tokens.d.ts +5 -5
  36. package/dist/apps/data/routes/webhooks.d.ts +1011 -151
  37. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  38. package/dist/apps/data/routes/webhooks.js +124 -737
  39. package/dist/apps/data/routes/webhooks.js.map +1 -1
  40. package/dist/apps/data/routes/zones.d.ts +31 -31
  41. package/dist/apps/funding/routes/deposit-addresses.d.ts +2 -2
  42. package/dist/apps/funding/routes/providers.d.ts +13 -13
  43. package/dist/apps/funding/routes/transfers.d.ts +33 -33
  44. package/dist/apps/management/App.d.ts +793 -16
  45. package/dist/apps/management/App.d.ts.map +1 -1
  46. package/dist/apps/management/routes/api-keys.d.ts +50 -50
  47. package/dist/apps/management/routes/faucet.d.ts +31 -31
  48. package/dist/apps/management/routes/invite-links.d.ts +42 -42
  49. package/dist/apps/management/routes/members.d.ts +79 -79
  50. package/dist/apps/management/routes/projects.d.ts +40 -40
  51. package/dist/apps/management/routes/usage.d.ts +80 -80
  52. package/dist/apps/management/routes/webhooks.d.ts +1031 -59
  53. package/dist/apps/management/routes/webhooks.d.ts.map +1 -1
  54. package/dist/apps/management/routes/webhooks.js +27 -4
  55. package/dist/apps/management/routes/webhooks.js.map +1 -1
  56. package/dist/db/Db.d.ts.map +1 -1
  57. package/dist/db/Db.js +21 -0
  58. package/dist/db/Db.js.map +1 -1
  59. package/dist/db/Schema.d.ts +7 -1
  60. package/dist/db/Schema.d.ts.map +1 -1
  61. package/dist/db/Schema.js +9 -0
  62. package/dist/db/Schema.js.map +1 -1
  63. package/dist/db/tables/webhookQueueEvents.d.ts +3 -1
  64. package/dist/db/tables/webhookQueueEvents.d.ts.map +1 -1
  65. package/dist/db/tables/webhookQueueEvents.js +4 -0
  66. package/dist/db/tables/webhookQueueEvents.js.map +1 -1
  67. package/dist/db/tables/webhookSubscriptions.d.ts +33 -69
  68. package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -1
  69. package/dist/db/tables/webhookSubscriptions.js +63 -100
  70. package/dist/db/tables/webhookSubscriptions.js.map +1 -1
  71. package/dist/internal/MetricSink.d.ts +35 -125
  72. package/dist/internal/MetricSink.d.ts.map +1 -1
  73. package/dist/internal/MetricSink.js +50 -121
  74. package/dist/internal/MetricSink.js.map +1 -1
  75. package/dist/internal/Schema.d.ts +3 -1
  76. package/dist/internal/Schema.d.ts.map +1 -1
  77. package/dist/internal/Schema.js +4 -2
  78. package/dist/internal/Schema.js.map +1 -1
  79. package/dist/internal/WebhookTransfer.d.ts +1 -55
  80. package/dist/internal/WebhookTransfer.d.ts.map +1 -1
  81. package/dist/internal/WebhookTransfer.js +1 -239
  82. package/dist/internal/WebhookTransfer.js.map +1 -1
  83. package/dist/internal/Webhooks.d.ts +83 -44
  84. package/dist/internal/Webhooks.d.ts.map +1 -1
  85. package/dist/internal/Webhooks.js +87 -73
  86. package/dist/internal/Webhooks.js.map +1 -1
  87. package/dist/internal/funding/DepositWebhook.d.ts +25 -0
  88. package/dist/internal/funding/DepositWebhook.d.ts.map +1 -0
  89. package/dist/internal/funding/DepositWebhook.js +74 -0
  90. package/dist/internal/funding/DepositWebhook.js.map +1 -0
  91. package/dist/internal/funding/Reconciliation.d.ts +6 -0
  92. package/dist/internal/funding/Reconciliation.d.ts.map +1 -1
  93. package/dist/internal/funding/Reconciliation.js +42 -10
  94. package/dist/internal/funding/Reconciliation.js.map +1 -1
  95. package/dist/internal/funding/index.d.ts +1 -0
  96. package/dist/internal/funding/index.d.ts.map +1 -1
  97. package/dist/internal/funding/index.js +1 -0
  98. package/dist/internal/funding/index.js.map +1 -1
  99. package/dist/internal/webhookDestinations/slack.d.ts.map +1 -1
  100. package/dist/internal/webhookDestinations/slack.js +16 -0
  101. package/dist/internal/webhookDestinations/slack.js.map +1 -1
  102. package/package.json +1 -1
  103. package/src/App.ts +3 -0
  104. package/src/Client.test-d.ts +22 -1
  105. package/src/Webhooks.ts +1 -220
  106. package/src/apps/data/App.ts +8 -2
  107. package/src/apps/data/routes/activities.test.ts +107 -11
  108. package/src/apps/data/routes/activities.ts +64 -32
  109. package/src/apps/data/routes/webhooks.test.ts +179 -1497
  110. package/src/apps/data/routes/webhooks.ts +150 -974
  111. package/src/apps/management/routes/webhooks.test.ts +79 -1
  112. package/src/apps/management/routes/webhooks.ts +40 -4
  113. package/src/db/Db.test.ts +1 -5
  114. package/src/db/Db.ts +23 -0
  115. package/src/db/Schema.ts +13 -0
  116. package/src/db/tables/organizations.test.ts +5 -2
  117. package/src/db/tables/webhookQueueEvents.ts +7 -1
  118. package/src/db/tables/webhookSubscriptions.ts +89 -138
  119. package/src/internal/MetricSink.test.ts +41 -373
  120. package/src/internal/MetricSink.ts +92 -273
  121. package/src/internal/Schema.test.ts +18 -0
  122. package/src/internal/Schema.ts +13 -2
  123. package/src/internal/WebhookTransfer.ts +1 -351
  124. package/src/internal/Webhooks.test.ts +102 -139
  125. package/src/internal/Webhooks.ts +175 -106
  126. package/src/internal/funding/DepositWebhook.test.ts +131 -0
  127. package/src/internal/funding/DepositWebhook.ts +101 -0
  128. package/src/internal/funding/Reconciliation.test.ts +14 -0
  129. package/src/internal/funding/Reconciliation.ts +161 -86
  130. package/src/internal/funding/index.test.ts +1 -0
  131. package/src/internal/funding/index.ts +1 -0
  132. package/src/internal/webhookDestinations/slack.ts +15 -0
  133. package/src/Webhooks.bench.ts +0 -714
  134. package/src/Webhooks.memory.bench.ts +0 -250
  135. package/src/Webhooks.test.ts +0 -146
@@ -3,11 +3,10 @@ import { type AbiEvent, decodeEventLog, encodeEventTopics, parseAbiItem, type Rp
3
3
  import * as z from 'zod/mini'
4
4
 
5
5
  import type * as App from '../../../App.js'
6
+ import * as Scope from '../../../Scope.js'
6
7
  import * as Auth from '../../../internal/Auth.js'
7
8
  import * as Cache from '../../../internal/Cache.js'
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'
11
10
  import * as OpenApi from '../../../internal/OpenApi.js'
12
11
  import * as Response from '../../../internal/Response.js'
13
12
  import * as Schema from '../../../internal/Schema.js'
@@ -18,6 +17,7 @@ import type * as Viem from '../../../internal/Viem.js'
18
17
  import * as WebhookDestination from '../../../internal/WebhookDestination.js'
19
18
  import * as WebhookTransfer from '../../../internal/WebhookTransfer.js'
20
19
  import * as Webhooks from '../../../internal/Webhooks.js'
20
+ import * as FundingDeposit from '../../../internal/funding/Deposit.js'
21
21
  import * as Transactions from './transactions.js'
22
22
  import * as Transfers from './transfers.js'
23
23
 
@@ -41,6 +41,7 @@ const redactedSlackUrl = 'https://hooks.slack.com/…'
41
41
  */
42
42
  export const eventTypes = [
43
43
  'block:created',
44
+ 'funding:deposit.updated',
44
45
  'log:emitted',
45
46
  'token:transfer',
46
47
  'transaction:included',
@@ -56,6 +57,11 @@ const eventTypeDescriptions = [
56
57
  'A new block was added to the chain. Filter by producer, gas usage, or block number, or omit filters for a per-block heartbeat.',
57
58
  type: 'block:created',
58
59
  },
60
+ {
61
+ description:
62
+ 'A funding deposit materially changed after verified reconciliation, filterable by deposit address, recipient, or status.',
63
+ type: 'funding:deposit.updated',
64
+ },
59
65
  {
60
66
  description:
61
67
  'Any contract event log, filtered by emitting address, event signature/topics, or decoded arguments.',
@@ -447,6 +453,18 @@ export namespace schema {
447
453
  z.meta({ title: 'Block filters' }),
448
454
  )
449
455
 
456
+ /** `funding:deposit.updated` subscription filters. All are optional and AND-combined. */
457
+ export const FundingDepositFilters = z
458
+ .strictObject({
459
+ depositAddressId: z.optional(FundingDeposit.schema.Snapshot.shape.depositAddressId),
460
+ recipient: z.optional(Schema.Address),
461
+ status: z.optional(FundingDeposit.schema.Status),
462
+ })
463
+ .check(
464
+ z.describe('Filters that narrow which funding deposit changes trigger this webhook.'),
465
+ z.meta({ title: 'Funding deposit filters' }),
466
+ )
467
+
450
468
  /**
451
469
  * Optional human context describing what a subscription is for. Surfaced by
452
470
  * destinations (Slack header + description, Better Stack log fields) and echoed
@@ -506,6 +524,12 @@ export namespace schema {
506
524
  z.meta({ examples: ['2024-01-01T00:00:00Z'] }),
507
525
  ),
508
526
  destination: PublicDestination,
527
+ environment: z
528
+ .optional(z.enum(['production', 'sandbox']))
529
+ .check(
530
+ z.describe('API-key environment for private resource events, when applicable.'),
531
+ z.meta({ examples: ['production'] }),
532
+ ),
509
533
  eventType: EventType.check(z.meta({ examples: ['token:transfer'] })),
510
534
  expiresAt: z
511
535
  .optional(z.iso.datetime())
@@ -683,7 +707,7 @@ export namespace schema {
683
707
  })
684
708
  .check(
685
709
  z.describe(
686
- 'Best-effort token metadata (symbol/decimals) resolved by the poller so `amount` can render as a human amount. Omitted when the lookup is unavailable.',
710
+ 'Best-effort token metadata (symbol/decimals) resolved before delivery so `amount` can render as a human amount. Omitted when the lookup is unavailable.',
687
711
  ),
688
712
  ),
689
713
  ),
@@ -886,6 +910,9 @@ export namespace schema {
886
910
  })
887
911
  .check(z.describe('Payload for a `block:created` event (a new block header).'))
888
912
 
913
+ /** Public funding deposit after a committed material change. */
914
+ export const FundingDepositEvent = FundingDeposit.schema.FundingDeposit
915
+
889
916
  /** A `ping` (synthetic test-delivery) payload, sent by `POST /webhooks/:id/ping`. */
890
917
  export const PingEvent = z
891
918
  .object({
@@ -944,6 +971,13 @@ export namespace schema {
944
971
  z
945
972
  .object({ ...envelopeBase, data: BlockEvent, type: z.literal('block:created') })
946
973
  .check(z.meta({ title: 'Block event' })),
974
+ z
975
+ .object({
976
+ ...envelopeBase,
977
+ data: FundingDepositEvent,
978
+ type: z.literal('funding:deposit.updated'),
979
+ })
980
+ .check(z.meta({ title: 'Funding deposit updated event' })),
947
981
  z
948
982
  .object({ ...envelopeBase, data: PingEvent, type: z.literal('ping') })
949
983
  .check(z.meta({ title: 'Ping (test delivery)' })),
@@ -1037,6 +1071,20 @@ export namespace schema {
1037
1071
  filters: z.optional(BlockFilters),
1038
1072
  })
1039
1073
  .check(z.meta({ title: 'Block subscription' })),
1074
+ z
1075
+ .object({
1076
+ chainId: z.optional(Schema.ChainId).check(z.meta({ examples: [4217] })),
1077
+ context: z.optional(Context),
1078
+ destination: Destination,
1079
+ eventType: z
1080
+ .literal('funding:deposit.updated')
1081
+ .check(
1082
+ z.describe('Event type for committed funding deposit changes.'),
1083
+ z.meta({ examples: ['funding:deposit.updated'] }),
1084
+ ),
1085
+ filters: z.optional(FundingDepositFilters),
1086
+ })
1087
+ .check(z.meta({ title: 'Funding deposit subscription' })),
1040
1088
  ])
1041
1089
  .check(z.describe('Details for creating a webhook subscription.'))
1042
1090
 
@@ -1239,6 +1287,12 @@ export namespace schema {
1239
1287
  */
1240
1288
  export function webhooks(options: webhooks.Options = {}) {
1241
1289
  const hidden = !options.enabled
1290
+ const availableEventTypes = eventTypeDescriptions.filter(
1291
+ (eventType) =>
1292
+ eventType.type !== 'funding:deposit.updated' ||
1293
+ options.applicationEventTypes?.includes(eventType.type),
1294
+ )
1295
+ const availableEventTypeSet = new Set(availableEventTypes.map((eventType) => eventType.type))
1242
1296
  // Gate the whole resource on a configured state store. Without it the
1243
1297
  // outbound-HTTP surface is disabled (read-only deployment).
1244
1298
  const gate = async (c: Context<App.Environment>, next: () => Promise<void>) => {
@@ -1286,7 +1340,7 @@ export function webhooks(options: webhooks.Options = {}) {
1286
1340
  if (!c.get('webhook')) return notEnabled(c)
1287
1341
  return c.json(
1288
1342
  Response.validated(schema.getWebhookEventTypes.Response, {
1289
- data: eventTypeDescriptions.map((eventType) => ({ ...eventType })),
1343
+ data: availableEventTypes.map((eventType) => ({ ...eventType })),
1290
1344
  }),
1291
1345
  200,
1292
1346
  )
@@ -1307,12 +1361,18 @@ export function webhooks(options: webhooks.Options = {}) {
1307
1361
  responses: OpenApi.responses({
1308
1362
  errors: {
1309
1363
  400: {
1310
- codes: ['body_invalid', 'chain_id_unsupported', 'filters_invalid', 'url_invalid'],
1364
+ codes: [
1365
+ 'body_invalid',
1366
+ 'chain_id_unsupported',
1367
+ 'event_type_unsupported',
1368
+ 'filters_invalid',
1369
+ 'url_invalid',
1370
+ ],
1311
1371
  description: 'The request, destination, filters, or chain are invalid.',
1312
1372
  },
1313
1373
  403: {
1314
- codes: ['limit_exceeded'],
1315
- description: 'You have reached the webhook subscription limit.',
1374
+ codes: ['api_key_forbidden', 'limit_exceeded'],
1375
+ description: 'The API key lacks access or the subscription limit was reached.',
1316
1376
  },
1317
1377
  },
1318
1378
  success: {
@@ -1335,10 +1395,30 @@ export function webhooks(options: webhooks.Options = {}) {
1335
1395
 
1336
1396
  const webhook = c.get('webhook')!
1337
1397
  const db = Db.get(c.get('db'))
1398
+ const principal = Auth.getPrincipal(c)
1338
1399
  const owner = ownerFor(c)
1339
1400
  if (!owner) return unauthorized(c)
1340
1401
 
1341
1402
  const body = c.req.valid('json')
1403
+ if (!availableEventTypeSet.has(body.eventType))
1404
+ return Response.error(c, {
1405
+ code: 'event_type_unsupported',
1406
+ message: 'Webhook event type is not available in this deployment',
1407
+ status: 400,
1408
+ })
1409
+ if (body.eventType === 'funding:deposit.updated') {
1410
+ if (principal?.type !== 'api_key') return unauthorized(c)
1411
+ if (
1412
+ !principal.apiKey.scopes.some(
1413
+ (scope) => scope === Scope.wildcard || scope === 'funding:read',
1414
+ )
1415
+ )
1416
+ return Response.error(c, {
1417
+ code: 'api_key_forbidden',
1418
+ message: 'API key missing required scope',
1419
+ status: 403,
1420
+ })
1421
+ }
1342
1422
  const chainId = body.chainId ?? c.get('chainId')
1343
1423
  const supportedChainIds = new Set(webhook.supportedChainIds)
1344
1424
  if (!supportedChainIds.has(chainId))
@@ -1350,13 +1430,24 @@ export function webhooks(options: webhooks.Options = {}) {
1350
1430
  const maxPerOwner = webhook.maxPerOwner ?? defaultMaxPerOwner
1351
1431
  if ((await Webhooks.countSubscriptions(db, owner)) >= maxPerOwner)
1352
1432
  throw new Webhooks.LimitExceededError(maxPerOwner)
1353
- const startBlockNumber = await getRpcHead(c.get('getClient')(chainId))
1433
+ const startBlockNumber =
1434
+ body.eventType === 'funding:deposit.updated'
1435
+ ? undefined
1436
+ : await getRpcHead(c.get('getClient')(chainId))
1354
1437
  const subscription = await Webhooks.createSubscription(
1355
1438
  db,
1356
1439
  {
1357
1440
  chainId,
1358
1441
  ...(body.context === undefined ? {} : { context: body.context }),
1359
1442
  destination: body.destination,
1443
+ ...(principal?.type === 'api_key'
1444
+ ? {
1445
+ environment: principal.environment,
1446
+ ...(principal.projectId === undefined
1447
+ ? {}
1448
+ : { projectId: principal.projectId }),
1449
+ }
1450
+ : {}),
1360
1451
  eventType: body.eventType,
1361
1452
  filters: body.filters,
1362
1453
  owner,
@@ -1364,7 +1455,7 @@ export function webhooks(options: webhooks.Options = {}) {
1364
1455
  },
1365
1456
  {
1366
1457
  maxPerOwner,
1367
- startBlockNumber,
1458
+ ...(startBlockNumber === undefined ? {} : { startBlockNumber }),
1368
1459
  },
1369
1460
  )
1370
1461
  return c.json(
@@ -1408,6 +1499,7 @@ export function webhooks(options: webhooks.Options = {}) {
1408
1499
  const db = Db.get(c.get('db'))
1409
1500
  const owner = ownerFor(c)
1410
1501
  if (!owner) return unauthorized(c)
1502
+ const access = accessFor(c)
1411
1503
 
1412
1504
  const { cursor, include, limit, page } = c.req.valid('query')
1413
1505
  try {
@@ -1415,12 +1507,13 @@ export function webhooks(options: webhooks.Options = {}) {
1415
1507
  // run it concurrently with the page list. Best-effort: a failure
1416
1508
  // omits `meta` rather than failing the page.
1417
1509
  const countPromise = include.includes('totalCount')
1418
- ? Webhooks.countSubscriptions(db, owner).catch(() => undefined)
1510
+ ? Webhooks.countSubscriptions(db, owner, { access }).catch(() => undefined)
1419
1511
  : undefined
1420
1512
 
1421
1513
  // Fetch one extra to detect a further page without a second round-trip.
1422
1514
  // `page` translates to a positional slice of `(page - 1) * limit` rows.
1423
1515
  const rows = await Webhooks.listSubscriptions(db, owner, {
1516
+ access,
1424
1517
  cursor,
1425
1518
  limit: limit + 1,
1426
1519
  offset: page !== undefined && page > 1 ? (page - 1) * limit : undefined,
@@ -1488,10 +1581,11 @@ export function webhooks(options: webhooks.Options = {}) {
1488
1581
  const db = Db.get(c.get('db'))
1489
1582
  const owner = ownerFor(c)
1490
1583
  if (!owner) return unauthorized(c)
1584
+ const access = accessFor(c)
1491
1585
 
1492
1586
  const { id } = c.req.valid('param')
1493
1587
  try {
1494
- const subscription = await Webhooks.getSubscription(db, owner, id)
1588
+ const subscription = await Webhooks.getSubscription(db, owner, id, { access })
1495
1589
  if (!subscription) return notFound(c)
1496
1590
  return c.json(
1497
1591
  Response.validated(schema.getWebhook.Response, toPublic(subscription)),
@@ -1549,13 +1643,14 @@ export function webhooks(options: webhooks.Options = {}) {
1549
1643
  const db = Db.get(c.get('db'))
1550
1644
  const owner = ownerFor(c)
1551
1645
  if (!owner) return unauthorized(c)
1646
+ const access = accessFor(c)
1552
1647
 
1553
1648
  const { id } = c.req.valid('param')
1554
1649
  const { cursor, include, limit, page } = c.req.valid('query')
1555
1650
  try {
1556
1651
  // Owner-scope first: resolving the subscription under the caller's owner
1557
1652
  // key 404s ids they don't own (indistinguishable from "doesn't exist").
1558
- const subscription = await Webhooks.getSubscription(db, owner, id)
1653
+ const subscription = await Webhooks.getSubscription(db, owner, id, { access })
1559
1654
  if (!subscription) return notFound(c)
1560
1655
 
1561
1656
  // `totalCount` is opt-in and exact (the delivery log is TTL-bounded);
@@ -1632,11 +1727,12 @@ export function webhooks(options: webhooks.Options = {}) {
1632
1727
  const db = Db.get(c.get('db'))
1633
1728
  const owner = ownerFor(c)
1634
1729
  if (!owner) return unauthorized(c)
1730
+ const access = accessFor(c)
1635
1731
 
1636
1732
  const { id } = c.req.valid('param')
1637
1733
  try {
1638
1734
  // Owner-scope first so unowned/unknown ids are indistinguishable (404).
1639
- const subscription = await Webhooks.getSubscription(db, owner, id)
1735
+ const subscription = await Webhooks.getSubscription(db, owner, id, { access })
1640
1736
  if (!subscription) return notFound(c)
1641
1737
 
1642
1738
  // Inline delivery (not the queue) so the caller gets an immediate
@@ -1696,11 +1792,12 @@ export function webhooks(options: webhooks.Options = {}) {
1696
1792
  const db = Db.get(c.get('db'))
1697
1793
  const owner = ownerFor(c)
1698
1794
  if (!owner) return unauthorized(c)
1795
+ const access = accessFor(c)
1699
1796
 
1700
1797
  const { deliveryId, id } = c.req.valid('param')
1701
1798
  try {
1702
1799
  // Owner-scope the subscription first so unowned ids 404 like elsewhere.
1703
- const subscription = await Webhooks.getSubscription(db, owner, id)
1800
+ const subscription = await Webhooks.getSubscription(db, owner, id, { access })
1704
1801
  if (!subscription) return notFound(c)
1705
1802
 
1706
1803
  const delivery = await Webhooks.getDelivery(db, id, deliveryId)
@@ -1786,6 +1883,7 @@ export function webhooks(options: webhooks.Options = {}) {
1786
1883
  const db = Db.get(c.get('db'))
1787
1884
  const owner = ownerFor(c)
1788
1885
  if (!owner) return unauthorized(c)
1886
+ const access = accessFor(c)
1789
1887
 
1790
1888
  const { id } = c.req.valid('param')
1791
1889
  const patch = c.req.valid('json')
@@ -1796,11 +1894,11 @@ export function webhooks(options: webhooks.Options = {}) {
1796
1894
  // persisting — otherwise a bad filter would silently widen the match
1797
1895
  // set at scan time.
1798
1896
  if (patch.filters !== undefined) {
1799
- const existing = await Webhooks.getSubscription(db, owner, id)
1897
+ const existing = await Webhooks.getSubscription(db, owner, id, { access })
1800
1898
  if (!existing) return notFound(c)
1801
1899
  parseFilters(existing.eventType, patch.filters)
1802
1900
  }
1803
- const subscription = await Webhooks.updateSubscription(db, owner, id, patch)
1901
+ const subscription = await Webhooks.updateSubscription(db, owner, id, patch, { access })
1804
1902
  if (!subscription) return notFound(c)
1805
1903
  return c.json(
1806
1904
  Response.validated(schema.updateWebhook.Response, toPublic(subscription)),
@@ -1849,10 +1947,11 @@ export function webhooks(options: webhooks.Options = {}) {
1849
1947
  const db = Db.get(c.get('db'))
1850
1948
  const owner = ownerFor(c)
1851
1949
  if (!owner) return unauthorized(c)
1950
+ const access = accessFor(c)
1852
1951
 
1853
1952
  const { id } = c.req.valid('param')
1854
1953
  try {
1855
- const deleted = await Webhooks.deleteSubscription(db, owner, id)
1954
+ const deleted = await Webhooks.deleteSubscription(db, owner, id, { access })
1856
1955
  if (!deleted) return notFound(c)
1857
1956
  return c.json(Response.validated(schema.deleteWebhook.Response, { id }), 200)
1858
1957
  } catch (cause) {
@@ -1866,626 +1965,13 @@ export function webhooks(options: webhooks.Options = {}) {
1866
1965
  export declare namespace webhooks {
1867
1966
  /** Options for creating webhook routes. */
1868
1967
  type Options = {
1968
+ /** Application events produced by this deployment outside the per-block chain scan. */
1969
+ applicationEventTypes?: readonly Webhooks.EventType[] | undefined
1869
1970
  /** Whether webhook state is configured and routes should appear in OpenAPI. */
1870
1971
  enabled?: boolean | undefined
1871
1972
  }
1872
1973
  }
1873
1974
 
1874
- /**
1875
- * Largest representable in-block index, used as an end-of-block cursor sentinel
1876
- * so a fully-scanned block advances the cursor strictly past it (no row index
1877
- * can equal it).
1878
- */
1879
- const endOfBlockIndex = 2_147_483_647
1880
- const scanPageSize = 100
1881
-
1882
- /** A scanned row paired with its `(block, index)` keyset position. */
1883
- type ScanItem = { cursor: readonly [number, number]; data: unknown }
1884
-
1885
- /**
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.
1889
- */
1890
- type ScanPage = {
1891
- hasMore?: boolean | undefined
1892
- items: readonly ScanItem[]
1893
- scannedThrough?: number | undefined
1894
- }
1895
-
1896
- /** Per-subscription state retained between fair scan passes. */
1897
- type PollState = {
1898
- cursor: string
1899
- filters: Record<string, unknown>
1900
- remaining: number
1901
- safeBlock: number
1902
- subscription: Webhooks.Subscription
1903
- }
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
-
1956
- /** Returns `candidate` only when it is lexicographically after `current`. */
1957
- function laterCursor(current: string, candidate: string): string {
1958
- const before = Cursor.decode(current, ['int', 'int'])
1959
- const after = Cursor.decode(candidate, ['int', 'int'])
1960
- if (!after) return current
1961
- if (!before) return candidate
1962
- const [beforeBlock, beforeIndex] = before as [number, number]
1963
- const [afterBlock, afterIndex] = after as [number, number]
1964
- return afterBlock > beforeBlock || (afterBlock === beforeBlock && afterIndex > beforeIndex)
1965
- ? candidate
1966
- : current
1967
- }
1968
-
1969
- /**
1970
- * Polls the chain over RPC for new events and dispatches a signed envelope per
1971
- * matching row, advancing each active subscription's `(block, index)` keyset
1972
- * cursor.
1973
- *
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.
1977
- *
1978
- * The chain head is injectable (tests pin a fixed historical window); only
1979
- * blocks at least `confirmations` behind head are emitted, giving at-least-once,
1980
- * finalized-ish semantics (re-delivery overlap is harmless because the receiver
1981
- * dedupes on the stable `event.id`).
1982
- *
1983
- * Cursor advances only past successfully dispatched rows, so a dispatch failure
1984
- * pauses that subscription at the last good position and the next tick resumes.
1985
- * Scan failures leave that subscription unchanged; the first error is rethrown
1986
- * after the remaining subscriptions run.
1987
- */
1988
- export async function poll(options: poll.Options): Promise<poll.Result> {
1989
- const {
1990
- batchSize = 200,
1991
- cache: cache_metadata = Store.memory(),
1992
- chainId,
1993
- client,
1994
- confirmations = 1,
1995
- db,
1996
- eventTypes: eventTypes_input = [
1997
- 'token:transfer',
1998
- 'transaction:included',
1999
- 'log:emitted',
2000
- 'block:created',
2001
- ],
2002
- maxBlocks = 1_000,
2003
- } = options
2004
- const now = options.now ?? (() => new Date())
2005
- options.signal?.throwIfAborted()
2006
- // Default (self-host) transport: deliver inline and record the outcome so a
2007
- // sustained-failure subscription auto-disables. On Cloudflare, callers pass a
2008
- // Queue producer instead and the consumer runs `deliverAndRecord`.
2009
- const dispatch =
2010
- options.dispatch ??
2011
- (({ envelope, subscription }) => Webhooks.deliverAndRecord(db, subscription, envelope))
2012
- const dispatchBatch = options.dispatchBatch
2013
-
2014
- // A pinned `headBlockNumber` lets a head subscription supply the exact live
2015
- // boundary and keeps historical tests deterministic.
2016
- const override = options.headBlockNumber
2017
- let rpcHead: number | undefined = override === undefined ? undefined : Number(override)
2018
- const resolveRpcHead = async () => (rpcHead ??= await getRpcHead(client))
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
- })()
2039
- const result: poll.Result = {
2040
- chainId,
2041
- dispatched: 0,
2042
- failed: 0,
2043
- safeBlock: rpcSafeBlock,
2044
- scanned: 0,
2045
- subscriptions: 0,
2046
- }
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
- }
2098
- }
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,
2154
- })
2155
- }
2156
-
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({
2189
- client,
2190
- eventType,
2191
- maxBlocks,
2192
- schedule: scheduleScan,
2193
- states: batch,
2194
- })
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,
2211
- })
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 })
2230
- }
2231
-
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 }]
2245
- })
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
- }
2267
-
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 = await enrichEventData(eventType, item.data, {
2280
- cache: cache_metadata,
2281
- chainId,
2282
- client,
2283
- })
2284
- durations.enrich += Date.now() - enrichStartedAt
2285
- observed.add('enrich')
2286
- options.signal?.throwIfAborted()
2287
- const envelope = Webhooks.buildEnvelope({
2288
- blockNumber: item.cursor[0],
2289
- createdAt: now(),
2290
- data,
2291
- logIndex: item.cursor[1],
2292
- subscription,
2293
- })
2294
- return { envelope, subscription }
2295
- }
2296
-
2297
- if (dispatchBatch) {
2298
- const dispatchables: poll.Dispatchable[] = []
2299
- for (const item of page.items) dispatchables.push(await buildDispatchable(item))
2300
- options.signal?.throwIfAborted()
2301
- const delivered = await dispatchBatch(dispatchables, { db })
2302
- if (delivered.ok) {
2303
- accepted = page.items.at(-1)?.cursor
2304
- result.dispatched += dispatchables.length
2305
- } else {
2306
- result.failed += dispatchables.length
2307
- failed = true
2308
- }
2309
- } else {
2310
- for (const item of page.items) {
2311
- const dispatchable = await buildDispatchable(item)
2312
- const delivered = await dispatch(dispatchable)
2313
- if (!delivered.ok) {
2314
- result.failed++
2315
- failed = true
2316
- break
2317
- }
2318
- accepted = item.cursor
2319
- result.dispatched++
2320
- }
2321
- }
2322
-
2323
- // Persist every accepted page before another subscription begins its
2324
- // continuation, so a later scan failure retains completed work.
2325
- const candidate = (() => {
2326
- if (failed) return accepted ? Cursor.encode([...accepted]) : undefined
2327
- if (page.scannedThrough !== undefined)
2328
- return Cursor.encode([page.scannedThrough, endOfBlockIndex])
2329
- if (accepted) return Cursor.encode([...accepted])
2330
- if (!page.hasMore) return Cursor.encode([state.safeBlock, endOfBlockIndex])
2331
- return undefined
2332
- })()
2333
- const cursor = candidate ? laterCursor(state.cursor, candidate) : state.cursor
2334
- const progressed = cursor !== state.cursor
2335
- let committed = false
2336
- if (progressed) {
2337
- committed = await writeCursor({
2338
- cursor,
2339
- expectedCursor: state.cursor,
2340
- subscriptionId: subscription.id,
2341
- })
2342
- if (committed) state.cursor = cursor
2343
- }
2344
-
2345
- if (!failed && page.hasMore === true) {
2346
- if (committed && state.remaining > 0) continuations.push(state)
2347
- else result.hasMore = true
2348
- }
2349
- }
2350
- }
2351
- states = continuations
2352
- }
2353
- for (const stage of observed) {
2354
- const outcome = (() => {
2355
- if (stage === 'cursor_write') return cursorWriteOutcome
2356
- if (stage === 'scan') return scanOutcome
2357
- return 'ok'
2358
- })()
2359
- options.onStage?.({
2360
- durationMs: durations[stage],
2361
- eventType,
2362
- outcome,
2363
- stage,
2364
- })
2365
- }
2366
- },
2367
- )
2368
- for (const result of eventTypeResults)
2369
- if (result.status === 'rejected') pollErrors.push(result.reason)
2370
-
2371
- options.onCursorLag?.(cursorLagBlocks)
2372
- if (pollErrors.length > 0) throw pollErrors[0]
2373
- return result
2374
- }
2375
-
2376
- export declare namespace poll {
2377
- /** A row to dispatch, paired with its owning subscription. */
2378
- type Dispatchable = {
2379
- /** The signed-on-dispatch delivery envelope. */
2380
- envelope: Webhooks.Envelope
2381
- /** The subscription the event matched. */
2382
- subscription: Webhooks.Subscription
2383
- }
2384
- /** Options for {@link poll}. */
2385
- type Options = {
2386
- /** Max matching rows scanned per subscription per tick (default 200). */
2387
- batchSize?: number | undefined
2388
- /** Cache store for the token-metadata memoize (defaults to a fresh in-memory store; pass a persistent one to share lookups across ticks). */
2389
- cache?: Store.Store | undefined
2390
- /** Chain to poll. Callers loop over chains. */
2391
- chainId: number
2392
- /** viem RPC client for the chain. */
2393
- client: Viem.getClient.ReturnType
2394
- /** Reorg-safety window: only emit blocks this far behind head (default 1). */
2395
- confirmations?: number | undefined
2396
- /** Database holding subscriptions + cursors. */
2397
- db: Db.Db
2398
- /**
2399
- * Delivery transport. Defaults to inline {@link Webhooks.deliverAndRecord} (self-host);
2400
- * on Cloudflare pass a Queue producer so retry/backoff/DLQ are native.
2401
- */
2402
- dispatch?: ((input: Dispatchable) => Promise<Webhooks.Result>) | undefined
2403
- /** Batch delivery transport. A successful result confirms every row in the input. */
2404
- dispatchBatch?:
2405
- | ((input: readonly Dispatchable[], context: DispatchContext) => Promise<Webhooks.Result>)
2406
- | undefined
2407
- /** Event types to poll (default all). */
2408
- eventTypes?: readonly EventType[] | undefined
2409
- /** Current chain head; the poller emits up to `head - confirmations`. */
2410
- headBlockNumber?: bigint | number | undefined
2411
- /** Backfill cap: most blocks an RPC scanner inspects per tick (default 1000). */
2412
- maxBlocks?: number | undefined
2413
- /** Records the worst confirmed-head distance without changing the poll result. */
2414
- onCursorLag?: ((blocks: number) => void) | undefined
2415
- /** Records bounded scan inputs before allocation and outcomes after completion. */
2416
- onScanCheckpoint?: ((checkpoint: ScanCheckpoint) => void) | undefined
2417
- /** Records bounded, per-tick poll-stage timings without subscription identities. */
2418
- onStage?: ((stage: Stage) => void) | undefined
2419
- /** Records subscriptions eligible for this poll before any scan runs. */
2420
- onSubscriptions?: ((count: number) => void) | undefined
2421
- /** Clock for envelope timestamps; injectable for tests. */
2422
- now?: (() => Date) | undefined
2423
- /** Stops scanning and dispatch when the caller loses its execution lease. */
2424
- signal?: AbortSignal | undefined
2425
- }
2426
-
2427
- /** Runtime context shared with a batch delivery transport. */
2428
- type DispatchContext = {
2429
- /** Database instance already resolved for this poll. */
2430
- db: Db.Db
2431
- }
2432
- /** Summary counters for one poll tick. */
2433
- type Result = {
2434
- /** Chain that was polled. */
2435
- chainId: number
2436
- /** Rows successfully dispatched. */
2437
- dispatched: number
2438
- /** Rows whose dispatch failed (pausing that subscription). */
2439
- failed: number
2440
- /** True when a bounded scan left committed work behind the same head. */
2441
- hasMore?: boolean | undefined
2442
- /** Highest confirmed block emitted this tick (`head - confirmations`). */
2443
- safeBlock: number
2444
- /** Total rows scanned across all subscriptions. */
2445
- scanned: number
2446
- /** Active subscriptions visited. */
2447
- subscriptions: number
2448
- }
2449
- /** One scan boundary used to correlate production memory failures with workload shape. */
2450
- type ScanCheckpoint = {
2451
- /** Shared upper bound for concurrent upstream scans across event types. */
2452
- concurrency: number
2453
- /** Scan duration, present only after completion. */
2454
- durationMs?: number | undefined
2455
- /** Subscription event type scanned by this batch. */
2456
- eventType: EventType
2457
- /** Subscriptions whose scan rejected, present only after completion. */
2458
- failedSubscriptions?: number | undefined
2459
- /** Matching rows retained by completed pages, present only after completion. */
2460
- matchedItems?: number | undefined
2461
- /** Largest block window requested by one subscription. */
2462
- maxWindowBlocks: number
2463
- /** Whether the checkpoint precedes allocation or follows scan completion. */
2464
- phase: 'complete' | 'start'
2465
- /** Outcome of the completed RPC-backed scan. */
2466
- outcome?: 'empty' | 'error' | 'missing' | 'ok' | undefined
2467
- /** JSON-RPC method used by this event type's scanner. */
2468
- rpcMethod: 'eth_getBlockByNumber' | 'eth_getLogs'
2469
- /** Subscriptions retained in this scan batch. */
2470
- subscriptionCount: number
2471
- /** Maximum subscriptions retained by this event-type batch. */
2472
- subscriptionLimit: number
2473
- /** Sum of subscription block windows in this scan batch. */
2474
- totalWindowBlocks: number
2475
- }
2476
- /** One bounded poll-stage timing observation. */
2477
- type Stage = {
2478
- /** Stage duration in milliseconds. */
2479
- durationMs: number
2480
- /** Event type being processed, omitted for the shared RPC-head stage. */
2481
- eventType?: EventType | undefined
2482
- /** Whether the stage completed. */
2483
- outcome: 'error' | 'ok'
2484
- /** Bounded stage name. */
2485
- stage: MetricSink.PollStage
2486
- }
2487
- }
2488
-
2489
1975
  /**
2490
1976
  * Attaches best-effort `{ symbol, decimals }` token metadata to a
2491
1977
  * `token:transfer` row so destinations can render human amounts. Reads the row's
@@ -2596,78 +2082,9 @@ export async function getRpcHead(client: Viem.getClient.ReturnType): Promise<num
2596
2082
  return Number(await client.getBlockNumber())
2597
2083
  }
2598
2084
 
2599
- /** Groups aligned cursors only when their bounded upstream scans are equivalent. */
2600
- async function scanSubscriptions(options: {
2601
- client: Viem.getClient.ReturnType
2602
- eventType: EventType
2603
- maxBlocks: number
2604
- schedule: Schedule
2605
- states: readonly PollState[]
2606
- }): Promise<PromiseSettledResult<ScanPage>[]> {
2607
- const { client, eventType, maxBlocks, schedule, states } = options
2608
- if (eventType !== 'token:transfer')
2609
- return scanIndependent({ client, eventType, maxBlocks, schedule, states })
2610
- return WebhookTransfer.scan({
2611
- client,
2612
- concurrency: pollConcurrency,
2613
- maxBlocks,
2614
- schedule,
2615
- states: states.map((state) => ({
2616
- filters: state.filters,
2617
- limit: Math.min(scanPageSize, state.remaining),
2618
- window: scanWindow(state.cursor, state.safeBlock, maxBlocks),
2619
- })),
2620
- })
2621
- }
2622
-
2623
- /** Returns the bounded JSON-RPC method used by an event scanner. */
2624
- function scanRpcMethod(eventType: EventType): poll.ScanCheckpoint['rpcMethod'] {
2625
- return eventType === 'block:created' || eventType === 'transaction:included'
2626
- ? 'eth_getBlockByNumber'
2627
- : 'eth_getLogs'
2628
- }
2629
-
2630
- /** Classifies a completed batch without exposing an unbounded upstream error. */
2631
- function scanResultOutcome(
2632
- pages: readonly PromiseSettledResult<ScanPage>[],
2633
- ): NonNullable<poll.ScanCheckpoint['outcome']> {
2634
- const rejected = pages.filter((page) => page.status === 'rejected')
2635
- if (rejected.some((page) => page.reason instanceof BlockUnavailableError)) return 'missing'
2636
- if (rejected.length > 0) return 'error'
2637
- return pages.every((page) => page.status === 'fulfilled' && page.value.items.length === 0)
2638
- ? 'empty'
2639
- : 'ok'
2640
- }
2641
-
2642
- /** Scans CPU-matched streams per subscription while sharing concurrent block reads. */
2643
- function scanIndependent(options: {
2644
- client: Viem.getClient.ReturnType
2645
- eventType: Exclude<EventType, 'token:transfer'>
2646
- maxBlocks: number
2647
- schedule: Schedule
2648
- states: readonly PollState[]
2649
- }): Promise<PromiseSettledResult<ScanPage>[]> {
2650
- const { client, eventType, maxBlocks, schedule, states } = options
2651
- const cache = createChainCache(client)
2652
- return mapWithLimit(states, pollConcurrency, (state) =>
2653
- schedule(() => {
2654
- const shared = {
2655
- batchSize: Math.min(scanPageSize, state.remaining),
2656
- cache,
2657
- cursor: state.cursor,
2658
- filters: state.filters,
2659
- maxBlocks,
2660
- safeBlock: state.safeBlock,
2661
- }
2662
- if (eventType === 'block:created') return scanBlocks(shared)
2663
- if (eventType === 'log:emitted') return scanLogs({ ...shared, client })
2664
- return scanTxs(shared)
2665
- }),
2666
- )
2667
- }
2668
-
2669
2085
  const filterSchemas = {
2670
2086
  'block:created': schema.BlockFilters,
2087
+ 'funding:deposit.updated': schema.FundingDepositFilters,
2671
2088
  'log:emitted': schema.LogFilters,
2672
2089
  'token:transfer': schema.TransferFilters,
2673
2090
  'transaction:included': schema.TxFilters,
@@ -2676,7 +2093,7 @@ const filterSchemas = {
2676
2093
  /**
2677
2094
  * Validates `filters` against the schema for `eventType`, **failing closed**: an
2678
2095
  * invalid filter throws {@link Webhooks.InvalidFilterError} instead of degrading
2679
- * to `{}` (a firehose). Used at create/patch time and by the scanners so a stored
2096
+ * to `{}` (a firehose). Used at create/patch time and by {@link scanBlock} so a stored
2680
2097
  * bad filter can never silently widen the match set.
2681
2098
  */
2682
2099
  export function parseFilters(
@@ -2732,7 +2149,7 @@ function operandEquals(value: string, target: unknown, kind: 'number' | 'string'
2732
2149
  * The JS counterpart of the old SQL `applyOperator`: evaluates one parsed filter
2733
2150
  * operator (from {@link schema.eq}/{@link schema.compare}/{@link schema.bytes})
2734
2151
  * against a single `0x`-hex field value pulled from an RPC object. The one place
2735
- * operator semantics live, shared by every RPC scanner. Returns `false` for a
2152
+ * operator semantics live, shared by every event matcher. Returns `false` for a
2736
2153
  * missing field so an absent value never matches (fail-closed).
2737
2154
  *
2738
2155
  * - bare value / `{ eq }` → equality
@@ -2784,107 +2201,15 @@ function matchesCalls(calls: unknown, op: unknown): boolean {
2784
2201
  return has(op)
2785
2202
  }
2786
2203
 
2787
- // RPC window helpers + per-tick lazy cache.
2788
-
2789
- /** Renders a block number as a `0x`-hex tag for JSON-RPC params. */
2790
- const blockTag = (n: number) => `0x${n.toString(16)}` as const
2791
-
2792
- /** Issues a JSON-RPC request through the viem client for a method viem can't type. */
2793
- function rpc<result>(
2794
- client: Viem.getClient.ReturnType,
2795
- method: string,
2796
- params: readonly unknown[],
2797
- ): Promise<result> {
2798
- return (client.request as (args: { method: string; params: unknown[] }) => Promise<result>)({
2799
- method,
2800
- params: [...params],
2801
- })
2802
- }
2803
-
2804
- /**
2805
- * The confirmed scan window for a cursor. A cursor `(cb, ci)` means rows through
2806
- * `(cb, ci)` were dispatched: when `ci` is the end-of-block sentinel the whole
2807
- * block `cb` is done and scanning resumes at `cb + 1`; otherwise block `cb` is
2808
- * re-scanned (skipping indices `<= ci`). `to` is capped by both `safeBlock` and
2809
- * the per-tick backfill cap. Returns `undefined` when the subscription is
2810
- * already caught up (nothing to scan, cursor unchanged).
2811
- */
2812
- function scanWindow(
2813
- cursor: string | undefined,
2814
- safeBlock: number,
2815
- maxBlocks: number,
2816
- ): { from: number; skip: readonly [number, number]; to: number } | undefined {
2817
- const decoded = cursor ? Cursor.decode(cursor, ['int', 'int']) : undefined
2818
- const cb = decoded ? (decoded[0] as number) : 0
2819
- const ci = decoded ? (decoded[1] as number) : endOfBlockIndex
2820
- const from = ci >= endOfBlockIndex ? cb + 1 : cb
2821
- if (from > safeBlock) return undefined
2822
- const to = Math.min(safeBlock, from + maxBlocks - 1)
2823
- return { from, skip: [cb, ci], to }
2824
- }
2825
-
2826
- /** True when `(block, index)` is at or before the already-dispatched cursor. */
2827
- function atOrBefore(block: number, index: number, skip: readonly [number, number]): boolean {
2828
- return block < skip[0] || (block === skip[0] && index <= skip[1])
2829
- }
2830
-
2831
2204
  /** A viem-formatted block header (no transactions), as returned by `getBlock`. */
2832
2205
  type RpcBlock = Awaited<ReturnType<Viem.getClient.ReturnType['getBlock']>>
2833
2206
 
2834
- /** Per-batch single-flight cache for chain RPC reads. */
2835
- type ChainCache = {
2836
- /** Full raw block (with transactions) via `eth_getBlockByNumber(n, true)`. */
2837
- block(n: number): Promise<Record<string, unknown>>
2838
- /** Viem-formatted block header (transaction hashes only) via `getBlock`. */
2839
- header(n: number): Promise<RpcBlock | undefined>
2840
- }
2841
-
2842
- /** Builds a per-batch {@link ChainCache} bound to a viem client. */
2843
- function createChainCache(client: Viem.getClient.ReturnType): ChainCache {
2844
- const headers = new Map<number, Promise<RpcBlock | undefined>>()
2845
- const blocks = new Map<number, Promise<Record<string, unknown>>>()
2846
-
2847
- const singleFlight = <key, value>(
2848
- pending: Map<key, Promise<value>>,
2849
- key: key,
2850
- load: () => Promise<value>,
2851
- ) => {
2852
- const existing = pending.get(key)
2853
- if (existing) return existing
2854
- const promise = load().finally(() => pending.delete(key))
2855
- pending.set(key, promise)
2856
- return promise
2857
- }
2858
-
2859
- return {
2860
- block(n) {
2861
- return singleFlight(blocks, n, () =>
2862
- // The transaction payload reuses `humanizeTransaction`, which consumes
2863
- // the raw RPC tx shape, so the full block stays on the untyped path.
2864
- rpc<Record<string, unknown> | null>(client, 'eth_getBlockByNumber', [
2865
- blockTag(n),
2866
- true,
2867
- ]).then((block) => {
2868
- if (!block) throw new BlockUnavailableError(n)
2869
- return block
2870
- }),
2871
- )
2872
- },
2873
- header(n) {
2874
- return singleFlight(headers, n, () =>
2875
- client.getBlock({ blockNumber: BigInt(n), includeTransactions: false }),
2876
- )
2877
- },
2878
- }
2879
- }
2880
-
2881
- // log:emitted (eth_getLogs)
2207
+ // log:emitted
2882
2208
 
2883
2209
  /**
2884
- * A viem-formatted log, as returned by `getLogs`. When a `signature`/`event` is
2885
- * supplied to `getLogs`, viem additionally best-effort decodes `args`/`eventName`
2886
- * (and filters to logs matching the event selector), so the manual
2887
- * `decodeEventLog`/selector-anchoring the scanner used to do is no longer needed.
2210
+ * A viem-shaped log consumed by the log matchers. `args`/`eventName` are present
2211
+ * once {@link decodeScanLog} best-effort decodes the log against a subscription
2212
+ * event signature.
2888
2213
  */
2889
2214
  type ScanLog = {
2890
2215
  address: string
@@ -2913,10 +2238,9 @@ type LogEventData = {
2913
2238
  }
2914
2239
 
2915
2240
  /**
2916
- * Builds the delivered `log:emitted` payload from a viem-formatted log plus the
2241
+ * Builds the delivered `log:emitted` payload from a viem-shaped log plus the
2917
2242
  * block timestamp. Returns `undefined` when a required field is missing/invalid
2918
- * (the row is skipped). When a `signature`/`event` is supplied, `getLogs` has
2919
- * already decoded `args`/`eventName`, so this just serializes them.
2243
+ * (the row is skipped). Decoded `args`/`eventName` are serialized when present.
2920
2244
  */
2921
2245
  function toLogEvent(
2922
2246
  log: ScanLog,
@@ -2985,7 +2309,7 @@ function pushHex(op: unknown): `0x${string}` | `0x${string}`[] | undefined {
2985
2309
  return undefined
2986
2310
  }
2987
2311
 
2988
- /** Narrows an `eq`/`in` address operator into the `getLogs` address pushdown. */
2312
+ /** Narrows an `eq`/`in` address operator into a pushdown-capable address value. */
2989
2313
  function pushAddress(op: unknown) {
2990
2314
  return pushHex(op)
2991
2315
  }
@@ -3001,7 +2325,7 @@ function parseEvent(signature: string | undefined): AbiEvent | undefined {
3001
2325
  }
3002
2326
  }
3003
2327
 
3004
- /** Returns exact indexed arguments that viem can push into `eth_getLogs`. */
2328
+ /** Returns exact indexed arguments usable as a selective log anchor. */
3005
2329
  function pushEventArgs(args: Record<string, unknown> | undefined, event: AbiEvent) {
3006
2330
  const pushed: Record<string, unknown> = {}
3007
2331
  for (const [name, op] of Object.entries(args ?? {})) {
@@ -3012,7 +2336,7 @@ function pushEventArgs(args: Record<string, unknown> | undefined, event: AbiEven
3012
2336
  return pushed
3013
2337
  }
3014
2338
 
3015
- /** Checks that the accepted filter has a selective `eth_getLogs` representation. */
2339
+ /** Checks that the accepted filter carries a selective anchor rather than matching every log. */
3016
2340
  function hasLogPushdown(f: z.output<typeof schema.LogFilters>): boolean {
3017
2341
  if (pushAddress(f.address) !== undefined) return true
3018
2342
 
@@ -3025,19 +2349,7 @@ function hasLogPushdown(f: z.output<typeof schema.LogFilters>): boolean {
3025
2349
  return pushHex(f.topic0) !== undefined && secondaryTopic
3026
2350
  }
3027
2351
 
3028
- /** Builds raw topic filters, preserving null wildcards between constrained positions. */
3029
- function pushTopics(f: z.output<typeof schema.LogFilters>, event: AbiEvent | undefined) {
3030
- const topics: (`0x${string}` | `0x${string}`[] | null | undefined)[] = [
3031
- event ? encodeEventTopics({ abi: [event] })[0] : pushHex(f.topic0),
3032
- pushHex(f.topic1),
3033
- pushHex(f.topic2),
3034
- pushHex(f.topic3),
3035
- ]
3036
- while (topics.length > 0 && topics.at(-1) === undefined) topics.pop()
3037
- return topics.map((topic) => topic ?? null)
3038
- }
3039
-
3040
- /** Converts one raw JSON-RPC log into the viem-shaped scanner input. */
2352
+ /** Converts one raw JSON-RPC log into the viem-shaped matcher input. */
3041
2353
  function fromRpcLog(log: RpcLog): ScanLog {
3042
2354
  return {
3043
2355
  address: log.address,
@@ -3095,78 +2407,6 @@ export declare namespace enrichEventData {
3095
2407
  }
3096
2408
  }
3097
2409
 
3098
- /** Scans contract logs for one subscription, then re-checks every filter in memory. */
3099
- async function scanLogs(options: {
3100
- batchSize: number
3101
- cache: ChainCache
3102
- client: Viem.getClient.ReturnType
3103
- cursor: string | undefined
3104
- filters: Record<string, unknown>
3105
- maxBlocks: number
3106
- safeBlock: number
3107
- }): Promise<ScanPage> {
3108
- const { batchSize, cache, client, cursor, filters, maxBlocks, safeBlock } = options
3109
- const f = filters as z.output<typeof schema.LogFilters>
3110
- const window = scanWindow(cursor, safeBlock, maxBlocks)
3111
- if (!window) return { items: [], scannedThrough: undefined }
3112
-
3113
- const event = parseEvent(f.signature)
3114
- if (f.signature && (!event || !/^[A-Za-z_]\w*$/.test(event.name)))
3115
- throw new Webhooks.InvalidFilterError('log:emitted', [
3116
- { message: 'Unsupported event signature.', path: ['signature'] },
3117
- ])
3118
-
3119
- const address = pushAddress(f.address)
3120
- const topics = pushTopics(f, event)
3121
- const hasRawTopics = topics.slice(1).some((topic) => topic !== null)
3122
- const args = event ? pushEventArgs(f.args, event) : {}
3123
- const logs = (!event || hasRawTopics
3124
- ? (
3125
- await rpc<RpcLog[]>(client, 'eth_getLogs', [
3126
- {
3127
- ...(address ? { address } : {}),
3128
- fromBlock: blockTag(window.from),
3129
- ...(topics.length > 0 ? { topics } : {}),
3130
- toBlock: blockTag(window.to),
3131
- },
3132
- ])
3133
- )
3134
- .map(fromRpcLog)
3135
- .map((log) => (event ? decodeScanLog(log, event) : log))
3136
- : await client.getLogs({
3137
- ...(address ? { address } : {}),
3138
- ...(Object.keys(args).length > 0 ? { args: args as never } : {}),
3139
- event,
3140
- fromBlock: BigInt(window.from),
3141
- strict: false,
3142
- toBlock: BigInt(window.to),
3143
- })) as unknown as ScanLog[]
3144
- logs.sort((a, b) => {
3145
- const block = Number(a.blockNumber ?? 0n) - Number(b.blockNumber ?? 0n)
3146
- return block || (a.logIndex ?? 0) - (b.logIndex ?? 0)
3147
- })
3148
-
3149
- const items: ScanItem[] = []
3150
- for (const log of logs) {
3151
- const blockNumber = log.blockNumber === null ? undefined : Number(log.blockNumber)
3152
- const logIndex = log.logIndex ?? undefined
3153
- if (blockNumber === undefined || logIndex === undefined) continue
3154
- if (atOrBefore(blockNumber, logIndex, window.skip)) continue
3155
- if (!matchesLog(log, f, event)) continue
3156
- const data = toLogEvent(
3157
- log,
3158
- Value.blockToIso(await cache.header(blockNumber)),
3159
- f.signature,
3160
- event,
3161
- )
3162
- if (data) {
3163
- items.push({ cursor: [data.blockNumber, data.logIndex], data })
3164
- if (items.length >= batchSize) return { hasMore: true, items }
3165
- }
3166
- }
3167
- return { items, scannedThrough: window.to }
3168
- }
3169
-
3170
2410
  /** Re-checks all log filters (including non-pushable operators) in JS. */
3171
2411
  function matchesLog(
3172
2412
  log: ScanLog,
@@ -3184,15 +2424,15 @@ function matchesLog(
3184
2424
  !matchesOperator(log.blockNumber?.toString(), f.blockNumber, 'number')
3185
2425
  )
3186
2426
  return false
3187
- // `getLogs(event)` already anchors topic0 on the event selector, so the raw
3188
- // `topic0` operator only applies in raw (no-signature) mode.
2427
+ // With a signature, topic0 is anchored on the event selector above, so the
2428
+ // raw `topic0` operator only applies in raw (no-signature) mode.
3189
2429
  if (!event && f.topic0 !== undefined && !matchesOperator(topics[0], f.topic0)) return false
3190
2430
  if (f.topic1 !== undefined && !matchesOperator(topics[1], f.topic1)) return false
3191
2431
  if (f.topic2 !== undefined && !matchesOperator(topics[2], f.topic2)) return false
3192
2432
  if (f.topic3 !== undefined && !matchesOperator(topics[3], f.topic3)) return false
3193
2433
 
3194
2434
  if (event && f.args && Object.keys(f.args).length > 0) {
3195
- // `getLogs(event, strict:false)` keeps non-decodable matches with no `args`.
2435
+ // Best-effort decoding leaves a non-decodable log without `args`.
3196
2436
  if (log.args === undefined || Array.isArray(log.args)) return false
3197
2437
  const args = log.args as Record<string, unknown>
3198
2438
  for (const [name, op] of Object.entries(f.args)) {
@@ -3210,7 +2450,7 @@ function matchesLog(
3210
2450
  return true
3211
2451
  }
3212
2452
 
3213
- // block:created (getBlock)
2453
+ // block:created
3214
2454
 
3215
2455
  type BlockEventData = {
3216
2456
  number: number
@@ -3255,35 +2495,6 @@ function toBlockEvent(block: RpcBlock): BlockEventData | undefined {
3255
2495
  }
3256
2496
  }
3257
2497
 
3258
- /** Scans new blocks over one subscription's confirmed window. */
3259
- async function scanBlocks(options: {
3260
- batchSize: number
3261
- cache: ChainCache
3262
- cursor: string | undefined
3263
- filters: Record<string, unknown>
3264
- maxBlocks: number
3265
- safeBlock: number
3266
- }): Promise<ScanPage> {
3267
- const { batchSize, cache, cursor, filters, maxBlocks, safeBlock } = options
3268
- const f = filters as z.output<typeof schema.BlockFilters>
3269
- const window = scanWindow(cursor, safeBlock, maxBlocks)
3270
- if (!window) return { items: [], scannedThrough: undefined }
3271
-
3272
- const items: ScanItem[] = []
3273
- for (let blockNumber = window.from; blockNumber <= window.to; blockNumber++) {
3274
- if (atOrBefore(blockNumber, 0, window.skip)) continue
3275
- const block = await cache.header(blockNumber)
3276
- if (!block) continue
3277
- if (!matchesBlock(block, f)) continue
3278
- const data = toBlockEvent(block)
3279
- if (data) {
3280
- items.push({ cursor: [data.number, 0], data })
3281
- if (items.length >= batchSize) return { hasMore: true, items }
3282
- }
3283
- }
3284
- return { items, scannedThrough: window.to }
3285
- }
3286
-
3287
2498
  /** Re-checks all block filters in JS. The RPC has no consensus `proposer`. */
3288
2499
  function matchesBlock(block: RpcBlock, f: z.output<typeof schema.BlockFilters>): boolean {
3289
2500
  if (f.number !== undefined && !matchesOperator(block.number?.toString(), f.number, 'number'))
@@ -3306,7 +2517,7 @@ function matchesBlock(block: RpcBlock, f: z.output<typeof schema.BlockFilters>):
3306
2517
  return true
3307
2518
  }
3308
2519
 
3309
- // transaction:included (eth_getBlockByNumber)
2520
+ // transaction:included
3310
2521
 
3311
2522
  /** Maps a `transaction:included` scalar filter field to its raw-tx field + kind. */
3312
2523
  const txFields = {
@@ -3324,47 +2535,6 @@ const txFields = {
3324
2535
  value: { field: 'value', kind: 'number' },
3325
2536
  } as const satisfies Record<string, { field: string; kind: 'number' | 'string' }>
3326
2537
 
3327
- /** Scans included transactions over one subscription's confirmed window. */
3328
- async function scanTxs(options: {
3329
- batchSize: number
3330
- cache: ChainCache
3331
- cursor: string | undefined
3332
- filters: Record<string, unknown>
3333
- maxBlocks: number
3334
- safeBlock: number
3335
- }): Promise<ScanPage> {
3336
- const { batchSize, cache, cursor, filters, maxBlocks, safeBlock } = options
3337
- const f = filters as z.output<typeof schema.TxFilters>
3338
- const window = scanWindow(cursor, safeBlock, maxBlocks)
3339
- if (!window) return { items: [], scannedThrough: undefined }
3340
-
3341
- const includeCalls = f.includeCalls ?? true
3342
- const items: ScanItem[] = []
3343
- for (let blockNumber = window.from; blockNumber <= window.to; blockNumber++) {
3344
- const block = await cache.block(blockNumber)
3345
- const transactions = Array.isArray(block['transactions'])
3346
- ? (block['transactions'] as unknown[])
3347
- : []
3348
- const timestamp = asString(block['timestamp'])
3349
- // A transaction's own `blockTimestamp` is seconds-only, so the delivered
3350
- // timestamp comes from the enclosing block, which carries milliseconds.
3351
- const blockTime = Value.blockToIso(block)
3352
- for (let offset = 0; offset < transactions.length; offset++) {
3353
- const transaction = transactions[offset] as Record<string, unknown>
3354
- const transactionIndex = Value.hexToNumber(transaction['transactionIndex']) ?? offset
3355
- if (atOrBefore(blockNumber, transactionIndex, window.skip)) continue
3356
- if (!matchesTx(transaction, f, includeCalls, timestamp, blockNumber)) continue
3357
- const parsed = Transactions.schema.Rpc.Transaction.safeParse(transaction)
3358
- if (!parsed.success) continue
3359
- const humanized = Transactions.humanizeTransaction(parsed.data)
3360
- const data = blockTime === undefined ? humanized : { ...humanized, timestamp: blockTime }
3361
- items.push({ cursor: [blockNumber, transactionIndex], data })
3362
- if (items.length >= batchSize) return { hasMore: true, items }
3363
- }
3364
- }
3365
- return { items, scannedThrough: window.to }
3366
- }
3367
-
3368
2538
  /** Re-checks transaction filters against the raw RPC transaction. */
3369
2539
  function matchesTx(
3370
2540
  tx: Record<string, unknown>,
@@ -3420,6 +2590,16 @@ function ownerFor(c: Context<App.Environment>): Webhooks.Owner | null {
3420
2590
  return payer ? { payer, type: 'mpp' } : null
3421
2591
  }
3422
2592
 
2593
+ function accessFor(c: Context<App.Environment>): Webhooks.Access {
2594
+ const principal = Auth.getPrincipal(c)
2595
+ if (principal?.type !== 'api_key') throw new Error('Webhook routes require an API key.')
2596
+ return {
2597
+ environment: principal.environment,
2598
+ ...(principal.projectId === undefined ? {} : { projectId: principal.projectId }),
2599
+ scopes: principal.apiKey.scopes,
2600
+ }
2601
+ }
2602
+
3423
2603
  /** Strips internal fields (`owner`, `secret`) from a subscription for reads. */
3424
2604
  function toPublic(subscription: Webhooks.Subscription) {
3425
2605
  return {
@@ -3427,6 +2607,10 @@ function toPublic(subscription: Webhooks.Subscription) {
3427
2607
  ...(subscription.context === undefined ? {} : { context: subscription.context }),
3428
2608
  createdAt: subscription.createdAt,
3429
2609
  destination: redactDestination(subscription.destination),
2610
+ ...(subscription.eventType === 'funding:deposit.updated' &&
2611
+ subscription.environment !== undefined
2612
+ ? { environment: subscription.environment }
2613
+ : {}),
3430
2614
  eventType: subscription.eventType,
3431
2615
  ...(subscription.expiresAt === undefined ? {} : { expiresAt: subscription.expiresAt }),
3432
2616
  failureCount: subscription.failureCount,
@@ -3709,11 +2893,3 @@ export declare namespace scanBlock {
3709
2893
  matches: readonly Match<subscription>[]
3710
2894
  }
3711
2895
  }
3712
-
3713
- class BlockUnavailableError extends Error {
3714
- override name = 'Webhooks.BlockUnavailableError'
3715
-
3716
- constructor(blockNumber: number) {
3717
- super(`Block ${blockNumber} is unavailable.`)
3718
- }
3719
- }