tapimo 0.10.1 → 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 (225) hide show
  1. package/dist/App.d.ts +5249 -3572
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/App.js.map +1 -1
  4. package/dist/Webhooks.d.ts +1 -76
  5. package/dist/Webhooks.d.ts.map +1 -1
  6. package/dist/Webhooks.js +2 -144
  7. package/dist/Webhooks.js.map +1 -1
  8. package/dist/admin/App.d.ts +74 -74
  9. package/dist/admin/apps/earn-vaults.d.ts +1 -1
  10. package/dist/admin/apps/organizations.d.ts +3 -3
  11. package/dist/admin/apps/verified-tokens.d.ts +74 -74
  12. package/dist/analytics/Analytics.d.ts.map +1 -1
  13. package/dist/analytics/Analytics.js +206 -0
  14. package/dist/analytics/Analytics.js.map +1 -1
  15. package/dist/analytics/tables/requestEvents.d.ts +4 -0
  16. package/dist/analytics/tables/requestEvents.d.ts.map +1 -1
  17. package/dist/analytics/tables/requestEvents.js +2 -0
  18. package/dist/analytics/tables/requestEvents.js.map +1 -1
  19. package/dist/apps/data/App.d.ts +3618 -2954
  20. package/dist/apps/data/App.d.ts.map +1 -1
  21. package/dist/apps/data/App.js +7 -2
  22. package/dist/apps/data/App.js.map +1 -1
  23. package/dist/apps/data/routes/activities.d.ts +1 -1
  24. package/dist/apps/data/routes/activities.d.ts.map +1 -1
  25. package/dist/apps/data/routes/activities.js +55 -27
  26. package/dist/apps/data/routes/activities.js.map +1 -1
  27. package/dist/apps/data/routes/balances.d.ts +11 -11
  28. package/dist/apps/data/routes/balances.d.ts.map +1 -1
  29. package/dist/apps/data/routes/balances.js +2 -6
  30. package/dist/apps/data/routes/balances.js.map +1 -1
  31. package/dist/apps/data/routes/blocks.d.ts +5 -5
  32. package/dist/apps/data/routes/earn.d.ts +4 -4
  33. package/dist/apps/data/routes/earn.d.ts.map +1 -1
  34. package/dist/apps/data/routes/earn.js +63 -34
  35. package/dist/apps/data/routes/earn.js.map +1 -1
  36. package/dist/apps/data/routes/exchanges.d.ts +8 -4
  37. package/dist/apps/data/routes/exchanges.d.ts.map +1 -1
  38. package/dist/apps/data/routes/exchanges.js +115 -77
  39. package/dist/apps/data/routes/exchanges.js.map +1 -1
  40. package/dist/apps/data/routes/fee-amm.d.ts +26 -26
  41. package/dist/apps/data/routes/receipts.d.ts +240 -239
  42. package/dist/apps/data/routes/receipts.d.ts.map +1 -1
  43. package/dist/apps/data/routes/receipts.js +15 -14
  44. package/dist/apps/data/routes/receipts.js.map +1 -1
  45. package/dist/apps/data/routes/tokenlist.d.ts +12 -12
  46. package/dist/apps/data/routes/tokens.d.ts +4 -4
  47. package/dist/apps/data/routes/tokens.d.ts.map +1 -1
  48. package/dist/apps/data/routes/tokens.js +4 -3
  49. package/dist/apps/data/routes/tokens.js.map +1 -1
  50. package/dist/apps/data/routes/transactions.d.ts +282 -282
  51. package/dist/apps/data/routes/transfers.d.ts +13 -13
  52. package/dist/apps/data/routes/transfers.d.ts.map +1 -1
  53. package/dist/apps/data/routes/transfers.js +25 -7
  54. package/dist/apps/data/routes/transfers.js.map +1 -1
  55. package/dist/apps/data/routes/valuation.d.ts +21 -21
  56. package/dist/apps/data/routes/verified-tokens.d.ts +5 -5
  57. package/dist/apps/data/routes/webhooks.d.ts +1519 -643
  58. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  59. package/dist/apps/data/routes/webhooks.js +134 -740
  60. package/dist/apps/data/routes/webhooks.js.map +1 -1
  61. package/dist/apps/data/routes/zones.d.ts +3 -3
  62. package/dist/apps/funding/App.d.ts +233 -0
  63. package/dist/apps/funding/App.d.ts.map +1 -1
  64. package/dist/apps/funding/routes/deposit-addresses.d.ts +331 -1
  65. package/dist/apps/funding/routes/deposit-addresses.d.ts.map +1 -1
  66. package/dist/apps/funding/routes/deposit-addresses.js +207 -11
  67. package/dist/apps/funding/routes/deposit-addresses.js.map +1 -1
  68. package/dist/apps/funding/routes/transfers.d.ts +33 -33
  69. package/dist/apps/management/App.d.ts +793 -16
  70. package/dist/apps/management/App.d.ts.map +1 -1
  71. package/dist/apps/management/routes/api-keys.d.ts +50 -50
  72. package/dist/apps/management/routes/faucet.d.ts +2 -2
  73. package/dist/apps/management/routes/invite-links.d.ts +42 -42
  74. package/dist/apps/management/routes/members.d.ts +2 -2
  75. package/dist/apps/management/routes/projects.d.ts +40 -40
  76. package/dist/apps/management/routes/webhooks.d.ts +1031 -59
  77. package/dist/apps/management/routes/webhooks.d.ts.map +1 -1
  78. package/dist/apps/management/routes/webhooks.js +27 -4
  79. package/dist/apps/management/routes/webhooks.js.map +1 -1
  80. package/dist/db/Db.d.ts.map +1 -1
  81. package/dist/db/Db.js +53 -0
  82. package/dist/db/Db.js.map +1 -1
  83. package/dist/db/Schema.d.ts +7 -1
  84. package/dist/db/Schema.d.ts.map +1 -1
  85. package/dist/db/Schema.js +9 -0
  86. package/dist/db/Schema.js.map +1 -1
  87. package/dist/db/tables/fundingDepositAddresses.d.ts +20 -0
  88. package/dist/db/tables/fundingDepositAddresses.d.ts.map +1 -1
  89. package/dist/db/tables/fundingDepositAddresses.js +17 -0
  90. package/dist/db/tables/fundingDepositAddresses.js.map +1 -1
  91. package/dist/db/tables/fundingDeposits.d.ts +41 -0
  92. package/dist/db/tables/fundingDeposits.d.ts.map +1 -1
  93. package/dist/db/tables/fundingDeposits.js +69 -0
  94. package/dist/db/tables/fundingDeposits.js.map +1 -1
  95. package/dist/db/tables/webhookQueueEvents.d.ts +3 -1
  96. package/dist/db/tables/webhookQueueEvents.d.ts.map +1 -1
  97. package/dist/db/tables/webhookQueueEvents.js +4 -0
  98. package/dist/db/tables/webhookQueueEvents.js.map +1 -1
  99. package/dist/db/tables/webhookSubscriptions.d.ts +33 -69
  100. package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -1
  101. package/dist/db/tables/webhookSubscriptions.js +63 -100
  102. package/dist/db/tables/webhookSubscriptions.js.map +1 -1
  103. package/dist/internal/Auth.d.ts +0 -26
  104. package/dist/internal/Auth.d.ts.map +1 -1
  105. package/dist/internal/Auth.js +0 -33
  106. package/dist/internal/Auth.js.map +1 -1
  107. package/dist/internal/Credential.d.ts +27 -0
  108. package/dist/internal/Credential.d.ts.map +1 -0
  109. package/dist/internal/Credential.js +34 -0
  110. package/dist/internal/Credential.js.map +1 -0
  111. package/dist/internal/EarnRates.js +11 -7
  112. package/dist/internal/EarnRates.js.map +1 -1
  113. package/dist/internal/Log.d.ts +8 -0
  114. package/dist/internal/Log.d.ts.map +1 -1
  115. package/dist/internal/Log.js +4 -0
  116. package/dist/internal/Log.js.map +1 -1
  117. package/dist/internal/MetricSink.d.ts +50 -116
  118. package/dist/internal/MetricSink.d.ts.map +1 -1
  119. package/dist/internal/MetricSink.js +60 -106
  120. package/dist/internal/MetricSink.js.map +1 -1
  121. package/dist/internal/Schema.d.ts +3 -1
  122. package/dist/internal/Schema.d.ts.map +1 -1
  123. package/dist/internal/Schema.js +4 -2
  124. package/dist/internal/Schema.js.map +1 -1
  125. package/dist/internal/Tidx.d.ts +4 -3
  126. package/dist/internal/Tidx.d.ts.map +1 -1
  127. package/dist/internal/Tidx.js +2 -2
  128. package/dist/internal/Tidx.js.map +1 -1
  129. package/dist/internal/Viem.d.ts +4 -3
  130. package/dist/internal/Viem.d.ts.map +1 -1
  131. package/dist/internal/Viem.js +3 -2
  132. package/dist/internal/Viem.js.map +1 -1
  133. package/dist/internal/WebhookTransfer.d.ts +1 -55
  134. package/dist/internal/WebhookTransfer.d.ts.map +1 -1
  135. package/dist/internal/WebhookTransfer.js +1 -239
  136. package/dist/internal/WebhookTransfer.js.map +1 -1
  137. package/dist/internal/Webhooks.d.ts +85 -44
  138. package/dist/internal/Webhooks.d.ts.map +1 -1
  139. package/dist/internal/Webhooks.js +94 -73
  140. package/dist/internal/Webhooks.js.map +1 -1
  141. package/dist/internal/funding/DepositWebhook.d.ts +25 -0
  142. package/dist/internal/funding/DepositWebhook.d.ts.map +1 -0
  143. package/dist/internal/funding/DepositWebhook.js +74 -0
  144. package/dist/internal/funding/DepositWebhook.js.map +1 -0
  145. package/dist/internal/funding/Reconciliation.d.ts +6 -0
  146. package/dist/internal/funding/Reconciliation.d.ts.map +1 -1
  147. package/dist/internal/funding/Reconciliation.js +42 -10
  148. package/dist/internal/funding/Reconciliation.js.map +1 -1
  149. package/dist/internal/funding/index.d.ts +1 -0
  150. package/dist/internal/funding/index.d.ts.map +1 -1
  151. package/dist/internal/funding/index.js +1 -0
  152. package/dist/internal/funding/index.js.map +1 -1
  153. package/dist/internal/index.d.ts +1 -0
  154. package/dist/internal/index.d.ts.map +1 -1
  155. package/dist/internal/index.js +1 -0
  156. package/dist/internal/index.js.map +1 -1
  157. package/dist/internal/webhookDestinations/slack.d.ts.map +1 -1
  158. package/dist/internal/webhookDestinations/slack.js +16 -0
  159. package/dist/internal/webhookDestinations/slack.js.map +1 -1
  160. package/package.json +1 -1
  161. package/src/App.test.ts +30 -0
  162. package/src/App.ts +3 -0
  163. package/src/Client.test-d.ts +55 -2
  164. package/src/Webhooks.ts +2 -221
  165. package/src/analytics/Analytics.test.ts +34 -0
  166. package/src/analytics/Analytics.ts +206 -0
  167. package/src/analytics/tables/requestEvents.ts +6 -0
  168. package/src/apps/composition.test.ts +1 -0
  169. package/src/apps/data/App.ts +10 -2
  170. package/src/apps/data/routes/activities.test.ts +107 -11
  171. package/src/apps/data/routes/activities.ts +64 -32
  172. package/src/apps/data/routes/balances.ts +2 -6
  173. package/src/apps/data/routes/earn.ts +64 -34
  174. package/src/apps/data/routes/exchanges.test.ts +8 -3
  175. package/src/apps/data/routes/exchanges.ts +134 -82
  176. package/src/apps/data/routes/receipts.ts +16 -14
  177. package/src/apps/data/routes/tokens.ts +3 -2
  178. package/src/apps/data/routes/transactions.bench.ts +8 -0
  179. package/src/apps/data/routes/transfers.ts +36 -10
  180. package/src/apps/data/routes/webhooks.test.ts +179 -1497
  181. package/src/apps/data/routes/webhooks.ts +172 -975
  182. package/src/apps/funding/routes/deposit-addresses.test.ts +216 -2
  183. package/src/apps/funding/routes/deposit-addresses.ts +254 -18
  184. package/src/apps/funding/routes/transfers.test.ts +17 -2
  185. package/src/apps/management/routes/webhooks.test.ts +79 -1
  186. package/src/apps/management/routes/webhooks.ts +40 -4
  187. package/src/db/Db.test.ts +1 -5
  188. package/src/db/Db.ts +59 -0
  189. package/src/db/Schema.ts +13 -0
  190. package/src/db/tables/fundingDepositAddresses.test.ts +28 -0
  191. package/src/db/tables/fundingDepositAddresses.ts +39 -0
  192. package/src/db/tables/fundingDeposits.test.ts +82 -0
  193. package/src/db/tables/fundingDeposits.ts +130 -0
  194. package/src/db/tables/organizations.test.ts +5 -2
  195. package/src/db/tables/webhookQueueEvents.ts +7 -1
  196. package/src/db/tables/webhookSubscriptions.ts +89 -138
  197. package/src/internal/Auth.test.ts +0 -48
  198. package/src/internal/Auth.ts +0 -59
  199. package/src/internal/Credential.test.ts +51 -0
  200. package/src/internal/Credential.ts +58 -0
  201. package/src/internal/EarnRates.ts +13 -7
  202. package/src/internal/Log.test.ts +3 -1
  203. package/src/internal/Log.ts +14 -0
  204. package/src/internal/MetricSink.test.ts +75 -358
  205. package/src/internal/MetricSink.ts +117 -246
  206. package/src/internal/Schema.test.ts +18 -0
  207. package/src/internal/Schema.ts +13 -2
  208. package/src/internal/Tidx.ts +5 -4
  209. package/src/internal/Viem.test.ts +4 -0
  210. package/src/internal/Viem.ts +6 -4
  211. package/src/internal/WebhookTransfer.ts +1 -351
  212. package/src/internal/Webhooks.test.ts +111 -139
  213. package/src/internal/Webhooks.ts +181 -105
  214. package/src/internal/funding/DepositWebhook.test.ts +131 -0
  215. package/src/internal/funding/DepositWebhook.ts +101 -0
  216. package/src/internal/funding/Reconciliation.test.ts +14 -0
  217. package/src/internal/funding/Reconciliation.ts +161 -86
  218. package/src/internal/funding/index.test.ts +1 -0
  219. package/src/internal/funding/index.ts +1 -0
  220. package/src/internal/index.test.ts +1 -0
  221. package/src/internal/index.ts +1 -0
  222. package/src/internal/webhookDestinations/slack.ts +15 -0
  223. package/src/Webhooks.bench.ts +0 -714
  224. package/src/Webhooks.memory.bench.ts +0 -250
  225. 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,631 +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 =
2280
- eventType === 'token:transfer'
2281
- ? await withTokenMetadata(item.data, { cache: cache_metadata, chainId, client })
2282
- : eventType === 'transaction:included'
2283
- ? await withFeeTokenMetadata(item.data, {
2284
- cache: cache_metadata,
2285
- chainId,
2286
- client,
2287
- })
2288
- : item.data
2289
- durations.enrich += Date.now() - enrichStartedAt
2290
- observed.add('enrich')
2291
- options.signal?.throwIfAborted()
2292
- const envelope = Webhooks.buildEnvelope({
2293
- blockNumber: item.cursor[0],
2294
- createdAt: now(),
2295
- data,
2296
- logIndex: item.cursor[1],
2297
- subscription,
2298
- })
2299
- return { envelope, subscription }
2300
- }
2301
-
2302
- if (dispatchBatch) {
2303
- const dispatchables: poll.Dispatchable[] = []
2304
- for (const item of page.items) dispatchables.push(await buildDispatchable(item))
2305
- options.signal?.throwIfAborted()
2306
- const delivered = await dispatchBatch(dispatchables, { db })
2307
- if (delivered.ok) {
2308
- accepted = page.items.at(-1)?.cursor
2309
- result.dispatched += dispatchables.length
2310
- } else {
2311
- result.failed += dispatchables.length
2312
- failed = true
2313
- }
2314
- } else {
2315
- for (const item of page.items) {
2316
- const dispatchable = await buildDispatchable(item)
2317
- const delivered = await dispatch(dispatchable)
2318
- if (!delivered.ok) {
2319
- result.failed++
2320
- failed = true
2321
- break
2322
- }
2323
- accepted = item.cursor
2324
- result.dispatched++
2325
- }
2326
- }
2327
-
2328
- // Persist every accepted page before another subscription begins its
2329
- // continuation, so a later scan failure retains completed work.
2330
- const candidate = (() => {
2331
- if (failed) return accepted ? Cursor.encode([...accepted]) : undefined
2332
- if (page.scannedThrough !== undefined)
2333
- return Cursor.encode([page.scannedThrough, endOfBlockIndex])
2334
- if (accepted) return Cursor.encode([...accepted])
2335
- if (!page.hasMore) return Cursor.encode([state.safeBlock, endOfBlockIndex])
2336
- return undefined
2337
- })()
2338
- const cursor = candidate ? laterCursor(state.cursor, candidate) : state.cursor
2339
- const progressed = cursor !== state.cursor
2340
- let committed = false
2341
- if (progressed) {
2342
- committed = await writeCursor({
2343
- cursor,
2344
- expectedCursor: state.cursor,
2345
- subscriptionId: subscription.id,
2346
- })
2347
- if (committed) state.cursor = cursor
2348
- }
2349
-
2350
- if (!failed && page.hasMore === true) {
2351
- if (committed && state.remaining > 0) continuations.push(state)
2352
- else result.hasMore = true
2353
- }
2354
- }
2355
- }
2356
- states = continuations
2357
- }
2358
- for (const stage of observed) {
2359
- const outcome = (() => {
2360
- if (stage === 'cursor_write') return cursorWriteOutcome
2361
- if (stage === 'scan') return scanOutcome
2362
- return 'ok'
2363
- })()
2364
- options.onStage?.({
2365
- durationMs: durations[stage],
2366
- eventType,
2367
- outcome,
2368
- stage,
2369
- })
2370
- }
2371
- },
2372
- )
2373
- for (const result of eventTypeResults)
2374
- if (result.status === 'rejected') pollErrors.push(result.reason)
2375
-
2376
- options.onCursorLag?.(cursorLagBlocks)
2377
- if (pollErrors.length > 0) throw pollErrors[0]
2378
- return result
2379
- }
2380
-
2381
- export declare namespace poll {
2382
- /** A row to dispatch, paired with its owning subscription. */
2383
- type Dispatchable = {
2384
- /** The signed-on-dispatch delivery envelope. */
2385
- envelope: Webhooks.Envelope
2386
- /** The subscription the event matched. */
2387
- subscription: Webhooks.Subscription
2388
- }
2389
- /** Options for {@link poll}. */
2390
- type Options = {
2391
- /** Max matching rows scanned per subscription per tick (default 200). */
2392
- batchSize?: number | undefined
2393
- /** Cache store for the token-metadata memoize (defaults to a fresh in-memory store; pass a persistent one to share lookups across ticks). */
2394
- cache?: Store.Store | undefined
2395
- /** Chain to poll. Callers loop over chains. */
2396
- chainId: number
2397
- /** viem RPC client for the chain. */
2398
- client: Viem.getClient.ReturnType
2399
- /** Reorg-safety window: only emit blocks this far behind head (default 1). */
2400
- confirmations?: number | undefined
2401
- /** Database holding subscriptions + cursors. */
2402
- db: Db.Db
2403
- /**
2404
- * Delivery transport. Defaults to inline {@link Webhooks.deliverAndRecord} (self-host);
2405
- * on Cloudflare pass a Queue producer so retry/backoff/DLQ are native.
2406
- */
2407
- dispatch?: ((input: Dispatchable) => Promise<Webhooks.Result>) | undefined
2408
- /** Batch delivery transport. A successful result confirms every row in the input. */
2409
- dispatchBatch?:
2410
- | ((input: readonly Dispatchable[], context: DispatchContext) => Promise<Webhooks.Result>)
2411
- | undefined
2412
- /** Event types to poll (default all). */
2413
- eventTypes?: readonly EventType[] | undefined
2414
- /** Current chain head; the poller emits up to `head - confirmations`. */
2415
- headBlockNumber?: bigint | number | undefined
2416
- /** Backfill cap: most blocks an RPC scanner inspects per tick (default 1000). */
2417
- maxBlocks?: number | undefined
2418
- /** Records the worst confirmed-head distance without changing the poll result. */
2419
- onCursorLag?: ((blocks: number) => void) | undefined
2420
- /** Records bounded scan inputs before allocation and outcomes after completion. */
2421
- onScanCheckpoint?: ((checkpoint: ScanCheckpoint) => void) | undefined
2422
- /** Records bounded, per-tick poll-stage timings without subscription identities. */
2423
- onStage?: ((stage: Stage) => void) | undefined
2424
- /** Records subscriptions eligible for this poll before any scan runs. */
2425
- onSubscriptions?: ((count: number) => void) | undefined
2426
- /** Clock for envelope timestamps; injectable for tests. */
2427
- now?: (() => Date) | undefined
2428
- /** Stops scanning and dispatch when the caller loses its execution lease. */
2429
- signal?: AbortSignal | undefined
2430
- }
2431
-
2432
- /** Runtime context shared with a batch delivery transport. */
2433
- type DispatchContext = {
2434
- /** Database instance already resolved for this poll. */
2435
- db: Db.Db
2436
- }
2437
- /** Summary counters for one poll tick. */
2438
- type Result = {
2439
- /** Chain that was polled. */
2440
- chainId: number
2441
- /** Rows successfully dispatched. */
2442
- dispatched: number
2443
- /** Rows whose dispatch failed (pausing that subscription). */
2444
- failed: number
2445
- /** True when a bounded scan left committed work behind the same head. */
2446
- hasMore?: boolean | undefined
2447
- /** Highest confirmed block emitted this tick (`head - confirmations`). */
2448
- safeBlock: number
2449
- /** Total rows scanned across all subscriptions. */
2450
- scanned: number
2451
- /** Active subscriptions visited. */
2452
- subscriptions: number
2453
- }
2454
- /** One scan boundary used to correlate production memory failures with workload shape. */
2455
- type ScanCheckpoint = {
2456
- /** Shared upper bound for concurrent upstream scans across event types. */
2457
- concurrency: number
2458
- /** Scan duration, present only after completion. */
2459
- durationMs?: number | undefined
2460
- /** Subscription event type scanned by this batch. */
2461
- eventType: EventType
2462
- /** Subscriptions whose scan rejected, present only after completion. */
2463
- failedSubscriptions?: number | undefined
2464
- /** Matching rows retained by completed pages, present only after completion. */
2465
- matchedItems?: number | undefined
2466
- /** Largest block window requested by one subscription. */
2467
- maxWindowBlocks: number
2468
- /** Whether the checkpoint precedes allocation or follows scan completion. */
2469
- phase: 'complete' | 'start'
2470
- /** Outcome of the completed RPC-backed scan. */
2471
- outcome?: 'empty' | 'error' | 'missing' | 'ok' | undefined
2472
- /** JSON-RPC method used by this event type's scanner. */
2473
- rpcMethod: 'eth_getBlockByNumber' | 'eth_getLogs'
2474
- /** Subscriptions retained in this scan batch. */
2475
- subscriptionCount: number
2476
- /** Maximum subscriptions retained by this event-type batch. */
2477
- subscriptionLimit: number
2478
- /** Sum of subscription block windows in this scan batch. */
2479
- totalWindowBlocks: number
2480
- }
2481
- /** One bounded poll-stage timing observation. */
2482
- type Stage = {
2483
- /** Stage duration in milliseconds. */
2484
- durationMs: number
2485
- /** Event type being processed, omitted for the shared RPC-head stage. */
2486
- eventType?: EventType | undefined
2487
- /** Whether the stage completed. */
2488
- outcome: 'error' | 'ok'
2489
- /** Bounded stage name. */
2490
- stage: MetricSink.PollStage
2491
- }
2492
- }
2493
-
2494
1975
  /**
2495
1976
  * Attaches best-effort `{ symbol, decimals }` token metadata to a
2496
1977
  * `token:transfer` row so destinations can render human amounts. Reads the row's
@@ -2601,78 +2082,9 @@ export async function getRpcHead(client: Viem.getClient.ReturnType): Promise<num
2601
2082
  return Number(await client.getBlockNumber())
2602
2083
  }
2603
2084
 
2604
- /** Groups aligned cursors only when their bounded upstream scans are equivalent. */
2605
- async function scanSubscriptions(options: {
2606
- client: Viem.getClient.ReturnType
2607
- eventType: EventType
2608
- maxBlocks: number
2609
- schedule: Schedule
2610
- states: readonly PollState[]
2611
- }): Promise<PromiseSettledResult<ScanPage>[]> {
2612
- const { client, eventType, maxBlocks, schedule, states } = options
2613
- if (eventType !== 'token:transfer')
2614
- return scanIndependent({ client, eventType, maxBlocks, schedule, states })
2615
- return WebhookTransfer.scan({
2616
- client,
2617
- concurrency: pollConcurrency,
2618
- maxBlocks,
2619
- schedule,
2620
- states: states.map((state) => ({
2621
- filters: state.filters,
2622
- limit: Math.min(scanPageSize, state.remaining),
2623
- window: scanWindow(state.cursor, state.safeBlock, maxBlocks),
2624
- })),
2625
- })
2626
- }
2627
-
2628
- /** Returns the bounded JSON-RPC method used by an event scanner. */
2629
- function scanRpcMethod(eventType: EventType): poll.ScanCheckpoint['rpcMethod'] {
2630
- return eventType === 'block:created' || eventType === 'transaction:included'
2631
- ? 'eth_getBlockByNumber'
2632
- : 'eth_getLogs'
2633
- }
2634
-
2635
- /** Classifies a completed batch without exposing an unbounded upstream error. */
2636
- function scanResultOutcome(
2637
- pages: readonly PromiseSettledResult<ScanPage>[],
2638
- ): NonNullable<poll.ScanCheckpoint['outcome']> {
2639
- const rejected = pages.filter((page) => page.status === 'rejected')
2640
- if (rejected.some((page) => page.reason instanceof BlockUnavailableError)) return 'missing'
2641
- if (rejected.length > 0) return 'error'
2642
- return pages.every((page) => page.status === 'fulfilled' && page.value.items.length === 0)
2643
- ? 'empty'
2644
- : 'ok'
2645
- }
2646
-
2647
- /** Scans CPU-matched streams per subscription while sharing concurrent block reads. */
2648
- function scanIndependent(options: {
2649
- client: Viem.getClient.ReturnType
2650
- eventType: Exclude<EventType, 'token:transfer'>
2651
- maxBlocks: number
2652
- schedule: Schedule
2653
- states: readonly PollState[]
2654
- }): Promise<PromiseSettledResult<ScanPage>[]> {
2655
- const { client, eventType, maxBlocks, schedule, states } = options
2656
- const cache = createChainCache(client)
2657
- return mapWithLimit(states, pollConcurrency, (state) =>
2658
- schedule(() => {
2659
- const shared = {
2660
- batchSize: Math.min(scanPageSize, state.remaining),
2661
- cache,
2662
- cursor: state.cursor,
2663
- filters: state.filters,
2664
- maxBlocks,
2665
- safeBlock: state.safeBlock,
2666
- }
2667
- if (eventType === 'block:created') return scanBlocks(shared)
2668
- if (eventType === 'log:emitted') return scanLogs({ ...shared, client })
2669
- return scanTxs(shared)
2670
- }),
2671
- )
2672
- }
2673
-
2674
2085
  const filterSchemas = {
2675
2086
  'block:created': schema.BlockFilters,
2087
+ 'funding:deposit.updated': schema.FundingDepositFilters,
2676
2088
  'log:emitted': schema.LogFilters,
2677
2089
  'token:transfer': schema.TransferFilters,
2678
2090
  'transaction:included': schema.TxFilters,
@@ -2681,7 +2093,7 @@ const filterSchemas = {
2681
2093
  /**
2682
2094
  * Validates `filters` against the schema for `eventType`, **failing closed**: an
2683
2095
  * invalid filter throws {@link Webhooks.InvalidFilterError} instead of degrading
2684
- * 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
2685
2097
  * bad filter can never silently widen the match set.
2686
2098
  */
2687
2099
  export function parseFilters(
@@ -2737,7 +2149,7 @@ function operandEquals(value: string, target: unknown, kind: 'number' | 'string'
2737
2149
  * The JS counterpart of the old SQL `applyOperator`: evaluates one parsed filter
2738
2150
  * operator (from {@link schema.eq}/{@link schema.compare}/{@link schema.bytes})
2739
2151
  * against a single `0x`-hex field value pulled from an RPC object. The one place
2740
- * 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
2741
2153
  * missing field so an absent value never matches (fail-closed).
2742
2154
  *
2743
2155
  * - bare value / `{ eq }` → equality
@@ -2789,107 +2201,15 @@ function matchesCalls(calls: unknown, op: unknown): boolean {
2789
2201
  return has(op)
2790
2202
  }
2791
2203
 
2792
- // RPC window helpers + per-tick lazy cache.
2793
-
2794
- /** Renders a block number as a `0x`-hex tag for JSON-RPC params. */
2795
- const blockTag = (n: number) => `0x${n.toString(16)}` as const
2796
-
2797
- /** Issues a JSON-RPC request through the viem client for a method viem can't type. */
2798
- function rpc<result>(
2799
- client: Viem.getClient.ReturnType,
2800
- method: string,
2801
- params: readonly unknown[],
2802
- ): Promise<result> {
2803
- return (client.request as (args: { method: string; params: unknown[] }) => Promise<result>)({
2804
- method,
2805
- params: [...params],
2806
- })
2807
- }
2808
-
2809
- /**
2810
- * The confirmed scan window for a cursor. A cursor `(cb, ci)` means rows through
2811
- * `(cb, ci)` were dispatched: when `ci` is the end-of-block sentinel the whole
2812
- * block `cb` is done and scanning resumes at `cb + 1`; otherwise block `cb` is
2813
- * re-scanned (skipping indices `<= ci`). `to` is capped by both `safeBlock` and
2814
- * the per-tick backfill cap. Returns `undefined` when the subscription is
2815
- * already caught up (nothing to scan, cursor unchanged).
2816
- */
2817
- function scanWindow(
2818
- cursor: string | undefined,
2819
- safeBlock: number,
2820
- maxBlocks: number,
2821
- ): { from: number; skip: readonly [number, number]; to: number } | undefined {
2822
- const decoded = cursor ? Cursor.decode(cursor, ['int', 'int']) : undefined
2823
- const cb = decoded ? (decoded[0] as number) : 0
2824
- const ci = decoded ? (decoded[1] as number) : endOfBlockIndex
2825
- const from = ci >= endOfBlockIndex ? cb + 1 : cb
2826
- if (from > safeBlock) return undefined
2827
- const to = Math.min(safeBlock, from + maxBlocks - 1)
2828
- return { from, skip: [cb, ci], to }
2829
- }
2830
-
2831
- /** True when `(block, index)` is at or before the already-dispatched cursor. */
2832
- function atOrBefore(block: number, index: number, skip: readonly [number, number]): boolean {
2833
- return block < skip[0] || (block === skip[0] && index <= skip[1])
2834
- }
2835
-
2836
2204
  /** A viem-formatted block header (no transactions), as returned by `getBlock`. */
2837
2205
  type RpcBlock = Awaited<ReturnType<Viem.getClient.ReturnType['getBlock']>>
2838
2206
 
2839
- /** Per-batch single-flight cache for chain RPC reads. */
2840
- type ChainCache = {
2841
- /** Full raw block (with transactions) via `eth_getBlockByNumber(n, true)`. */
2842
- block(n: number): Promise<Record<string, unknown>>
2843
- /** Viem-formatted block header (transaction hashes only) via `getBlock`. */
2844
- header(n: number): Promise<RpcBlock | undefined>
2845
- }
2846
-
2847
- /** Builds a per-batch {@link ChainCache} bound to a viem client. */
2848
- function createChainCache(client: Viem.getClient.ReturnType): ChainCache {
2849
- const headers = new Map<number, Promise<RpcBlock | undefined>>()
2850
- const blocks = new Map<number, Promise<Record<string, unknown>>>()
2851
-
2852
- const singleFlight = <key, value>(
2853
- pending: Map<key, Promise<value>>,
2854
- key: key,
2855
- load: () => Promise<value>,
2856
- ) => {
2857
- const existing = pending.get(key)
2858
- if (existing) return existing
2859
- const promise = load().finally(() => pending.delete(key))
2860
- pending.set(key, promise)
2861
- return promise
2862
- }
2863
-
2864
- return {
2865
- block(n) {
2866
- return singleFlight(blocks, n, () =>
2867
- // The transaction payload reuses `humanizeTransaction`, which consumes
2868
- // the raw RPC tx shape, so the full block stays on the untyped path.
2869
- rpc<Record<string, unknown> | null>(client, 'eth_getBlockByNumber', [
2870
- blockTag(n),
2871
- true,
2872
- ]).then((block) => {
2873
- if (!block) throw new BlockUnavailableError(n)
2874
- return block
2875
- }),
2876
- )
2877
- },
2878
- header(n) {
2879
- return singleFlight(headers, n, () =>
2880
- client.getBlock({ blockNumber: BigInt(n), includeTransactions: false }),
2881
- )
2882
- },
2883
- }
2884
- }
2885
-
2886
- // log:emitted (eth_getLogs)
2207
+ // log:emitted
2887
2208
 
2888
2209
  /**
2889
- * A viem-formatted log, as returned by `getLogs`. When a `signature`/`event` is
2890
- * supplied to `getLogs`, viem additionally best-effort decodes `args`/`eventName`
2891
- * (and filters to logs matching the event selector), so the manual
2892
- * `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.
2893
2213
  */
2894
2214
  type ScanLog = {
2895
2215
  address: string
@@ -2918,10 +2238,9 @@ type LogEventData = {
2918
2238
  }
2919
2239
 
2920
2240
  /**
2921
- * 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
2922
2242
  * block timestamp. Returns `undefined` when a required field is missing/invalid
2923
- * (the row is skipped). When a `signature`/`event` is supplied, `getLogs` has
2924
- * already decoded `args`/`eventName`, so this just serializes them.
2243
+ * (the row is skipped). Decoded `args`/`eventName` are serialized when present.
2925
2244
  */
2926
2245
  function toLogEvent(
2927
2246
  log: ScanLog,
@@ -2990,7 +2309,7 @@ function pushHex(op: unknown): `0x${string}` | `0x${string}`[] | undefined {
2990
2309
  return undefined
2991
2310
  }
2992
2311
 
2993
- /** Narrows an `eq`/`in` address operator into the `getLogs` address pushdown. */
2312
+ /** Narrows an `eq`/`in` address operator into a pushdown-capable address value. */
2994
2313
  function pushAddress(op: unknown) {
2995
2314
  return pushHex(op)
2996
2315
  }
@@ -3006,7 +2325,7 @@ function parseEvent(signature: string | undefined): AbiEvent | undefined {
3006
2325
  }
3007
2326
  }
3008
2327
 
3009
- /** Returns exact indexed arguments that viem can push into `eth_getLogs`. */
2328
+ /** Returns exact indexed arguments usable as a selective log anchor. */
3010
2329
  function pushEventArgs(args: Record<string, unknown> | undefined, event: AbiEvent) {
3011
2330
  const pushed: Record<string, unknown> = {}
3012
2331
  for (const [name, op] of Object.entries(args ?? {})) {
@@ -3017,7 +2336,7 @@ function pushEventArgs(args: Record<string, unknown> | undefined, event: AbiEven
3017
2336
  return pushed
3018
2337
  }
3019
2338
 
3020
- /** 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. */
3021
2340
  function hasLogPushdown(f: z.output<typeof schema.LogFilters>): boolean {
3022
2341
  if (pushAddress(f.address) !== undefined) return true
3023
2342
 
@@ -3030,19 +2349,7 @@ function hasLogPushdown(f: z.output<typeof schema.LogFilters>): boolean {
3030
2349
  return pushHex(f.topic0) !== undefined && secondaryTopic
3031
2350
  }
3032
2351
 
3033
- /** Builds raw topic filters, preserving null wildcards between constrained positions. */
3034
- function pushTopics(f: z.output<typeof schema.LogFilters>, event: AbiEvent | undefined) {
3035
- const topics: (`0x${string}` | `0x${string}`[] | null | undefined)[] = [
3036
- event ? encodeEventTopics({ abi: [event] })[0] : pushHex(f.topic0),
3037
- pushHex(f.topic1),
3038
- pushHex(f.topic2),
3039
- pushHex(f.topic3),
3040
- ]
3041
- while (topics.length > 0 && topics.at(-1) === undefined) topics.pop()
3042
- return topics.map((topic) => topic ?? null)
3043
- }
3044
-
3045
- /** 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. */
3046
2353
  function fromRpcLog(log: RpcLog): ScanLog {
3047
2354
  return {
3048
2355
  address: log.address,
@@ -3074,76 +2381,30 @@ function decodeScanLog(log: ScanLog, event: AbiEvent): ScanLog {
3074
2381
  }
3075
2382
  }
3076
2383
 
3077
- /** Scans contract logs for one subscription, then re-checks every filter in memory. */
3078
- async function scanLogs(options: {
3079
- batchSize: number
3080
- cache: ChainCache
3081
- client: Viem.getClient.ReturnType
3082
- cursor: string | undefined
3083
- filters: Record<string, unknown>
3084
- maxBlocks: number
3085
- safeBlock: number
3086
- }): Promise<ScanPage> {
3087
- const { batchSize, cache, client, cursor, filters, maxBlocks, safeBlock } = options
3088
- const f = filters as z.output<typeof schema.LogFilters>
3089
- const window = scanWindow(cursor, safeBlock, maxBlocks)
3090
- if (!window) return { items: [], scannedThrough: undefined }
3091
-
3092
- const event = parseEvent(f.signature)
3093
- if (f.signature && (!event || !/^[A-Za-z_]\w*$/.test(event.name)))
3094
- throw new Webhooks.InvalidFilterError('log:emitted', [
3095
- { message: 'Unsupported event signature.', path: ['signature'] },
3096
- ])
3097
-
3098
- const address = pushAddress(f.address)
3099
- const topics = pushTopics(f, event)
3100
- const hasRawTopics = topics.slice(1).some((topic) => topic !== null)
3101
- const args = event ? pushEventArgs(f.args, event) : {}
3102
- const logs = (!event || hasRawTopics
3103
- ? (
3104
- await rpc<RpcLog[]>(client, 'eth_getLogs', [
3105
- {
3106
- ...(address ? { address } : {}),
3107
- fromBlock: blockTag(window.from),
3108
- ...(topics.length > 0 ? { topics } : {}),
3109
- toBlock: blockTag(window.to),
3110
- },
3111
- ])
3112
- )
3113
- .map(fromRpcLog)
3114
- .map((log) => (event ? decodeScanLog(log, event) : log))
3115
- : await client.getLogs({
3116
- ...(address ? { address } : {}),
3117
- ...(Object.keys(args).length > 0 ? { args: args as never } : {}),
3118
- event,
3119
- fromBlock: BigInt(window.from),
3120
- strict: false,
3121
- toBlock: BigInt(window.to),
3122
- })) as unknown as ScanLog[]
3123
- logs.sort((a, b) => {
3124
- const block = Number(a.blockNumber ?? 0n) - Number(b.blockNumber ?? 0n)
3125
- return block || (a.logIndex ?? 0) - (b.logIndex ?? 0)
3126
- })
2384
+ /**
2385
+ * Attaches best-effort token metadata to one matched row before delivery.
2386
+ * Transfers gain display metadata; transactions resolve their fee token.
2387
+ */
2388
+ export async function enrichEventData(
2389
+ eventType: EventType,
2390
+ data: unknown,
2391
+ options: enrichEventData.Options,
2392
+ ): Promise<unknown> {
2393
+ if (eventType === 'token:transfer') return withTokenMetadata(data, options)
2394
+ if (eventType === 'transaction:included') return withFeeTokenMetadata(data, options)
2395
+ return data
2396
+ }
3127
2397
 
3128
- const items: ScanItem[] = []
3129
- for (const log of logs) {
3130
- const blockNumber = log.blockNumber === null ? undefined : Number(log.blockNumber)
3131
- const logIndex = log.logIndex ?? undefined
3132
- if (blockNumber === undefined || logIndex === undefined) continue
3133
- if (atOrBefore(blockNumber, logIndex, window.skip)) continue
3134
- if (!matchesLog(log, f, event)) continue
3135
- const data = toLogEvent(
3136
- log,
3137
- Value.blockToIso(await cache.header(blockNumber)),
3138
- f.signature,
3139
- event,
3140
- )
3141
- if (data) {
3142
- items.push({ cursor: [data.blockNumber, data.logIndex], data })
3143
- if (items.length >= batchSize) return { hasMore: true, items }
3144
- }
2398
+ export declare namespace enrichEventData {
2399
+ /** Metadata lookup dependencies. */
2400
+ type Options = {
2401
+ /** Memoization store for token metadata (5 minute TTL). */
2402
+ cache: Store.Store
2403
+ /** Chain the row was produced on. */
2404
+ chainId: number
2405
+ /** Client for `token.getMetadata` reads. */
2406
+ client: Viem.getClient.ReturnType
3145
2407
  }
3146
- return { items, scannedThrough: window.to }
3147
2408
  }
3148
2409
 
3149
2410
  /** Re-checks all log filters (including non-pushable operators) in JS. */
@@ -3163,15 +2424,15 @@ function matchesLog(
3163
2424
  !matchesOperator(log.blockNumber?.toString(), f.blockNumber, 'number')
3164
2425
  )
3165
2426
  return false
3166
- // `getLogs(event)` already anchors topic0 on the event selector, so the raw
3167
- // `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.
3168
2429
  if (!event && f.topic0 !== undefined && !matchesOperator(topics[0], f.topic0)) return false
3169
2430
  if (f.topic1 !== undefined && !matchesOperator(topics[1], f.topic1)) return false
3170
2431
  if (f.topic2 !== undefined && !matchesOperator(topics[2], f.topic2)) return false
3171
2432
  if (f.topic3 !== undefined && !matchesOperator(topics[3], f.topic3)) return false
3172
2433
 
3173
2434
  if (event && f.args && Object.keys(f.args).length > 0) {
3174
- // `getLogs(event, strict:false)` keeps non-decodable matches with no `args`.
2435
+ // Best-effort decoding leaves a non-decodable log without `args`.
3175
2436
  if (log.args === undefined || Array.isArray(log.args)) return false
3176
2437
  const args = log.args as Record<string, unknown>
3177
2438
  for (const [name, op] of Object.entries(f.args)) {
@@ -3189,7 +2450,7 @@ function matchesLog(
3189
2450
  return true
3190
2451
  }
3191
2452
 
3192
- // block:created (getBlock)
2453
+ // block:created
3193
2454
 
3194
2455
  type BlockEventData = {
3195
2456
  number: number
@@ -3234,35 +2495,6 @@ function toBlockEvent(block: RpcBlock): BlockEventData | undefined {
3234
2495
  }
3235
2496
  }
3236
2497
 
3237
- /** Scans new blocks over one subscription's confirmed window. */
3238
- async function scanBlocks(options: {
3239
- batchSize: number
3240
- cache: ChainCache
3241
- cursor: string | undefined
3242
- filters: Record<string, unknown>
3243
- maxBlocks: number
3244
- safeBlock: number
3245
- }): Promise<ScanPage> {
3246
- const { batchSize, cache, cursor, filters, maxBlocks, safeBlock } = options
3247
- const f = filters as z.output<typeof schema.BlockFilters>
3248
- const window = scanWindow(cursor, safeBlock, maxBlocks)
3249
- if (!window) return { items: [], scannedThrough: undefined }
3250
-
3251
- const items: ScanItem[] = []
3252
- for (let blockNumber = window.from; blockNumber <= window.to; blockNumber++) {
3253
- if (atOrBefore(blockNumber, 0, window.skip)) continue
3254
- const block = await cache.header(blockNumber)
3255
- if (!block) continue
3256
- if (!matchesBlock(block, f)) continue
3257
- const data = toBlockEvent(block)
3258
- if (data) {
3259
- items.push({ cursor: [data.number, 0], data })
3260
- if (items.length >= batchSize) return { hasMore: true, items }
3261
- }
3262
- }
3263
- return { items, scannedThrough: window.to }
3264
- }
3265
-
3266
2498
  /** Re-checks all block filters in JS. The RPC has no consensus `proposer`. */
3267
2499
  function matchesBlock(block: RpcBlock, f: z.output<typeof schema.BlockFilters>): boolean {
3268
2500
  if (f.number !== undefined && !matchesOperator(block.number?.toString(), f.number, 'number'))
@@ -3285,7 +2517,7 @@ function matchesBlock(block: RpcBlock, f: z.output<typeof schema.BlockFilters>):
3285
2517
  return true
3286
2518
  }
3287
2519
 
3288
- // transaction:included (eth_getBlockByNumber)
2520
+ // transaction:included
3289
2521
 
3290
2522
  /** Maps a `transaction:included` scalar filter field to its raw-tx field + kind. */
3291
2523
  const txFields = {
@@ -3303,47 +2535,6 @@ const txFields = {
3303
2535
  value: { field: 'value', kind: 'number' },
3304
2536
  } as const satisfies Record<string, { field: string; kind: 'number' | 'string' }>
3305
2537
 
3306
- /** Scans included transactions over one subscription's confirmed window. */
3307
- async function scanTxs(options: {
3308
- batchSize: number
3309
- cache: ChainCache
3310
- cursor: string | undefined
3311
- filters: Record<string, unknown>
3312
- maxBlocks: number
3313
- safeBlock: number
3314
- }): Promise<ScanPage> {
3315
- const { batchSize, cache, cursor, filters, maxBlocks, safeBlock } = options
3316
- const f = filters as z.output<typeof schema.TxFilters>
3317
- const window = scanWindow(cursor, safeBlock, maxBlocks)
3318
- if (!window) return { items: [], scannedThrough: undefined }
3319
-
3320
- const includeCalls = f.includeCalls ?? true
3321
- const items: ScanItem[] = []
3322
- for (let blockNumber = window.from; blockNumber <= window.to; blockNumber++) {
3323
- const block = await cache.block(blockNumber)
3324
- const transactions = Array.isArray(block['transactions'])
3325
- ? (block['transactions'] as unknown[])
3326
- : []
3327
- const timestamp = asString(block['timestamp'])
3328
- // A transaction's own `blockTimestamp` is seconds-only, so the delivered
3329
- // timestamp comes from the enclosing block, which carries milliseconds.
3330
- const blockTime = Value.blockToIso(block)
3331
- for (let offset = 0; offset < transactions.length; offset++) {
3332
- const transaction = transactions[offset] as Record<string, unknown>
3333
- const transactionIndex = Value.hexToNumber(transaction['transactionIndex']) ?? offset
3334
- if (atOrBefore(blockNumber, transactionIndex, window.skip)) continue
3335
- if (!matchesTx(transaction, f, includeCalls, timestamp, blockNumber)) continue
3336
- const parsed = Transactions.schema.Rpc.Transaction.safeParse(transaction)
3337
- if (!parsed.success) continue
3338
- const humanized = Transactions.humanizeTransaction(parsed.data)
3339
- const data = blockTime === undefined ? humanized : { ...humanized, timestamp: blockTime }
3340
- items.push({ cursor: [blockNumber, transactionIndex], data })
3341
- if (items.length >= batchSize) return { hasMore: true, items }
3342
- }
3343
- }
3344
- return { items, scannedThrough: window.to }
3345
- }
3346
-
3347
2538
  /** Re-checks transaction filters against the raw RPC transaction. */
3348
2539
  function matchesTx(
3349
2540
  tx: Record<string, unknown>,
@@ -3399,6 +2590,16 @@ function ownerFor(c: Context<App.Environment>): Webhooks.Owner | null {
3399
2590
  return payer ? { payer, type: 'mpp' } : null
3400
2591
  }
3401
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
+
3402
2603
  /** Strips internal fields (`owner`, `secret`) from a subscription for reads. */
3403
2604
  function toPublic(subscription: Webhooks.Subscription) {
3404
2605
  return {
@@ -3406,6 +2607,10 @@ function toPublic(subscription: Webhooks.Subscription) {
3406
2607
  ...(subscription.context === undefined ? {} : { context: subscription.context }),
3407
2608
  createdAt: subscription.createdAt,
3408
2609
  destination: redactDestination(subscription.destination),
2610
+ ...(subscription.eventType === 'funding:deposit.updated' &&
2611
+ subscription.environment !== undefined
2612
+ ? { environment: subscription.environment }
2613
+ : {}),
3409
2614
  eventType: subscription.eventType,
3410
2615
  ...(subscription.expiresAt === undefined ? {} : { expiresAt: subscription.expiresAt }),
3411
2616
  failureCount: subscription.failureCount,
@@ -3688,11 +2893,3 @@ export declare namespace scanBlock {
3688
2893
  matches: readonly Match<subscription>[]
3689
2894
  }
3690
2895
  }
3691
-
3692
- class BlockUnavailableError extends Error {
3693
- override name = 'Webhooks.BlockUnavailableError'
3694
-
3695
- constructor(blockNumber: number) {
3696
- super(`Block ${blockNumber} is unavailable.`)
3697
- }
3698
- }