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
@@ -1,9 +1,9 @@
1
1
  import { Hono } from 'hono';
2
2
  import { decodeEventLog, encodeEventTopics, parseAbiItem } from 'viem';
3
3
  import * as z from 'zod/mini';
4
+ import * as Scope from '../../../Scope.js';
4
5
  import * as Auth from '../../../internal/Auth.js';
5
6
  import * as Cache from '../../../internal/Cache.js';
6
- import * as Cursor from '../../../internal/Cursor.js';
7
7
  import * as Db from '../../../db/Db.js';
8
8
  import * as OpenApi from '../../../internal/OpenApi.js';
9
9
  import * as Response from '../../../internal/Response.js';
@@ -14,6 +14,7 @@ import * as Value from '../../../internal/Value.js';
14
14
  import * as WebhookDestination from '../../../internal/WebhookDestination.js';
15
15
  import * as WebhookTransfer from '../../../internal/WebhookTransfer.js';
16
16
  import * as Webhooks from '../../../internal/Webhooks.js';
17
+ import * as FundingDeposit from '../../../internal/funding/Deposit.js';
17
18
  import * as Transactions from './transactions.js';
18
19
  import * as Transfers from './transfers.js';
19
20
  /** Default cap on live subscriptions per owner, overridable via `webhook.maxPerOwner`. */
@@ -33,6 +34,7 @@ const redactedSlackUrl = 'https://hooks.slack.com/…';
33
34
  */
34
35
  export const eventTypes = [
35
36
  'block:created',
37
+ 'funding:deposit.updated',
36
38
  'log:emitted',
37
39
  'token:transfer',
38
40
  'transaction:included',
@@ -43,6 +45,10 @@ const eventTypeDescriptions = [
43
45
  description: 'A new block was added to the chain. Filter by producer, gas usage, or block number, or omit filters for a per-block heartbeat.',
44
46
  type: 'block:created',
45
47
  },
48
+ {
49
+ description: 'A funding deposit materially changed after verified reconciliation, filterable by deposit address, recipient, or status.',
50
+ type: 'funding:deposit.updated',
51
+ },
46
52
  {
47
53
  description: 'Any contract event log, filtered by emitting address, event signature/topics, or decoded arguments.',
48
54
  type: 'log:emitted',
@@ -268,6 +274,14 @@ export var schema;
268
274
  timestamp: z.optional(schema.compare.check(z.describe('Only match blocks whose unix timestamp (seconds) is within these bounds.'))),
269
275
  })
270
276
  .check(z.describe('Filters that narrow which new blocks trigger this webhook.'), z.meta({ title: 'Block filters' }));
277
+ /** `funding:deposit.updated` subscription filters. All are optional and AND-combined. */
278
+ schema.FundingDepositFilters = z
279
+ .strictObject({
280
+ depositAddressId: z.optional(FundingDeposit.schema.Snapshot.shape.depositAddressId),
281
+ recipient: z.optional(Schema.Address),
282
+ status: z.optional(FundingDeposit.schema.Status),
283
+ })
284
+ .check(z.describe('Filters that narrow which funding deposit changes trigger this webhook.'), z.meta({ title: 'Funding deposit filters' }));
271
285
  /**
272
286
  * Optional human context describing what a subscription is for. Surfaced by
273
287
  * destinations (Slack header + description, Better Stack log fields) and echoed
@@ -300,6 +314,9 @@ export var schema;
300
314
  .datetime()
301
315
  .check(z.describe('When the subscription was created, as an ISO 8601 timestamp.'), z.meta({ examples: ['2024-01-01T00:00:00Z'] })),
302
316
  destination: PublicDestination,
317
+ environment: z
318
+ .optional(z.enum(['production', 'sandbox']))
319
+ .check(z.describe('API-key environment for private resource events, when applicable.'), z.meta({ examples: ['production'] })),
303
320
  eventType: schema.EventType.check(z.meta({ examples: ['token:transfer'] })),
304
321
  expiresAt: z
305
322
  .optional(z.iso.datetime())
@@ -386,7 +403,7 @@ export var schema;
386
403
  .string()
387
404
  .check(z.describe('Token symbol (e.g. `USDC`).'), z.meta({ examples: ['USDC'] })),
388
405
  })
389
- .check(z.describe('Best-effort token metadata (symbol/decimals) resolved by the poller so `amount` can render as a human amount. Omitted when the lookup is unavailable.'))),
406
+ .check(z.describe('Best-effort token metadata (symbol/decimals) resolved before delivery so `amount` can render as a human amount. Omitted when the lookup is unavailable.'))),
390
407
  transactionHash: Schema.Hash.check(z.describe('Transaction hash that contains this transfer.'), z.meta({
391
408
  examples: ['0x3d24a706cc2f6f4c96620bef1f61ddb23040ff77c22c8db42918c7c424bbf9d3'],
392
409
  })),
@@ -489,6 +506,8 @@ export var schema;
489
506
  .check(z.describe('When the block was produced, as an ISO 8601 timestamp.'), z.meta({ examples: ['2026-01-14T18:38:03.685Z'] })),
490
507
  })
491
508
  .check(z.describe('Payload for a `block:created` event (a new block header).'));
509
+ /** Public funding deposit after a committed material change. */
510
+ schema.FundingDepositEvent = FundingDeposit.schema.FundingDeposit;
492
511
  /** A `ping` (synthetic test-delivery) payload, sent by `POST /webhooks/:id/ping`. */
493
512
  schema.PingEvent = z
494
513
  .object({
@@ -536,6 +555,13 @@ export var schema;
536
555
  z
537
556
  .object({ ...envelopeBase, data: schema.BlockEvent, type: z.literal('block:created') })
538
557
  .check(z.meta({ title: 'Block event' })),
558
+ z
559
+ .object({
560
+ ...envelopeBase,
561
+ data: schema.FundingDepositEvent,
562
+ type: z.literal('funding:deposit.updated'),
563
+ })
564
+ .check(z.meta({ title: 'Funding deposit updated event' })),
539
565
  z
540
566
  .object({ ...envelopeBase, data: schema.PingEvent, type: z.literal('ping') })
541
567
  .check(z.meta({ title: 'Ping (test delivery)' })),
@@ -612,6 +638,17 @@ export var schema;
612
638
  filters: z.optional(schema.BlockFilters),
613
639
  })
614
640
  .check(z.meta({ title: 'Block subscription' })),
641
+ z
642
+ .object({
643
+ chainId: z.optional(Schema.ChainId).check(z.meta({ examples: [4217] })),
644
+ context: z.optional(schema.Context),
645
+ destination: Destination,
646
+ eventType: z
647
+ .literal('funding:deposit.updated')
648
+ .check(z.describe('Event type for committed funding deposit changes.'), z.meta({ examples: ['funding:deposit.updated'] })),
649
+ filters: z.optional(schema.FundingDepositFilters),
650
+ })
651
+ .check(z.meta({ title: 'Funding deposit subscription' })),
615
652
  ])
616
653
  .check(z.describe('Details for creating a webhook subscription.'));
617
654
  createWebhook.Response = z
@@ -762,6 +799,9 @@ export var schema;
762
799
  */
763
800
  export function webhooks(options = {}) {
764
801
  const hidden = !options.enabled;
802
+ const availableEventTypes = eventTypeDescriptions.filter((eventType) => eventType.type !== 'funding:deposit.updated' ||
803
+ options.applicationEventTypes?.includes(eventType.type));
804
+ const availableEventTypeSet = new Set(availableEventTypes.map((eventType) => eventType.type));
765
805
  // Gate the whole resource on a configured state store. Without it the
766
806
  // outbound-HTTP surface is disabled (read-only deployment).
767
807
  const gate = async (c, next) => {
@@ -804,7 +844,7 @@ export function webhooks(options = {}) {
804
844
  if (!c.get('webhook'))
805
845
  return notEnabled(c);
806
846
  return c.json(Response.validated(schema.getWebhookEventTypes.Response, {
807
- data: eventTypeDescriptions.map((eventType) => ({ ...eventType })),
847
+ data: availableEventTypes.map((eventType) => ({ ...eventType })),
808
848
  }), 200);
809
849
  })
810
850
  .post('/v1/webhooks', Auth.policy({ apiKey: { scopes: ['webhooks:write'] } }), OpenApi.validate('json', schema.createWebhook.Body, {
@@ -817,12 +857,18 @@ export function webhooks(options = {}) {
817
857
  responses: OpenApi.responses({
818
858
  errors: {
819
859
  400: {
820
- codes: ['body_invalid', 'chain_id_unsupported', 'filters_invalid', 'url_invalid'],
860
+ codes: [
861
+ 'body_invalid',
862
+ 'chain_id_unsupported',
863
+ 'event_type_unsupported',
864
+ 'filters_invalid',
865
+ 'url_invalid',
866
+ ],
821
867
  description: 'The request, destination, filters, or chain are invalid.',
822
868
  },
823
869
  403: {
824
- codes: ['limit_exceeded'],
825
- description: 'You have reached the webhook subscription limit.',
870
+ codes: ['api_key_forbidden', 'limit_exceeded'],
871
+ description: 'The API key lacks access or the subscription limit was reached.',
826
872
  },
827
873
  },
828
874
  success: {
@@ -844,10 +890,27 @@ export function webhooks(options = {}) {
844
890
  });
845
891
  const webhook = c.get('webhook');
846
892
  const db = Db.get(c.get('db'));
893
+ const principal = Auth.getPrincipal(c);
847
894
  const owner = ownerFor(c);
848
895
  if (!owner)
849
896
  return unauthorized(c);
850
897
  const body = c.req.valid('json');
898
+ if (!availableEventTypeSet.has(body.eventType))
899
+ return Response.error(c, {
900
+ code: 'event_type_unsupported',
901
+ message: 'Webhook event type is not available in this deployment',
902
+ status: 400,
903
+ });
904
+ if (body.eventType === 'funding:deposit.updated') {
905
+ if (principal?.type !== 'api_key')
906
+ return unauthorized(c);
907
+ if (!principal.apiKey.scopes.some((scope) => scope === Scope.wildcard || scope === 'funding:read'))
908
+ return Response.error(c, {
909
+ code: 'api_key_forbidden',
910
+ message: 'API key missing required scope',
911
+ status: 403,
912
+ });
913
+ }
851
914
  const chainId = body.chainId ?? c.get('chainId');
852
915
  const supportedChainIds = new Set(webhook.supportedChainIds);
853
916
  if (!supportedChainIds.has(chainId))
@@ -858,18 +921,28 @@ export function webhooks(options = {}) {
858
921
  const maxPerOwner = webhook.maxPerOwner ?? defaultMaxPerOwner;
859
922
  if ((await Webhooks.countSubscriptions(db, owner)) >= maxPerOwner)
860
923
  throw new Webhooks.LimitExceededError(maxPerOwner);
861
- const startBlockNumber = await getRpcHead(c.get('getClient')(chainId));
924
+ const startBlockNumber = body.eventType === 'funding:deposit.updated'
925
+ ? undefined
926
+ : await getRpcHead(c.get('getClient')(chainId));
862
927
  const subscription = await Webhooks.createSubscription(db, {
863
928
  chainId,
864
929
  ...(body.context === undefined ? {} : { context: body.context }),
865
930
  destination: body.destination,
931
+ ...(principal?.type === 'api_key'
932
+ ? {
933
+ environment: principal.environment,
934
+ ...(principal.projectId === undefined
935
+ ? {}
936
+ : { projectId: principal.projectId }),
937
+ }
938
+ : {}),
866
939
  eventType: body.eventType,
867
940
  filters: body.filters,
868
941
  owner,
869
942
  ...(owner.type === 'mpp' ? { ttl: mppTtlMs } : {}),
870
943
  }, {
871
944
  maxPerOwner,
872
- startBlockNumber,
945
+ ...(startBlockNumber === undefined ? {} : { startBlockNumber }),
873
946
  });
874
947
  return c.json(Response.validated(schema.createWebhook.Response, toCreated(subscription)), 200);
875
948
  }
@@ -906,17 +979,19 @@ export function webhooks(options = {}) {
906
979
  const owner = ownerFor(c);
907
980
  if (!owner)
908
981
  return unauthorized(c);
982
+ const access = accessFor(c);
909
983
  const { cursor, include, limit, page } = c.req.valid('query');
910
984
  try {
911
985
  // `totalCount` is opt-in and exact (the per-owner set is bounded);
912
986
  // run it concurrently with the page list. Best-effort: a failure
913
987
  // omits `meta` rather than failing the page.
914
988
  const countPromise = include.includes('totalCount')
915
- ? Webhooks.countSubscriptions(db, owner).catch(() => undefined)
989
+ ? Webhooks.countSubscriptions(db, owner, { access }).catch(() => undefined)
916
990
  : undefined;
917
991
  // Fetch one extra to detect a further page without a second round-trip.
918
992
  // `page` translates to a positional slice of `(page - 1) * limit` rows.
919
993
  const rows = await Webhooks.listSubscriptions(db, owner, {
994
+ access,
920
995
  cursor,
921
996
  limit: limit + 1,
922
997
  offset: page !== undefined && page > 1 ? (page - 1) * limit : undefined,
@@ -976,9 +1051,10 @@ export function webhooks(options = {}) {
976
1051
  const owner = ownerFor(c);
977
1052
  if (!owner)
978
1053
  return unauthorized(c);
1054
+ const access = accessFor(c);
979
1055
  const { id } = c.req.valid('param');
980
1056
  try {
981
- const subscription = await Webhooks.getSubscription(db, owner, id);
1057
+ const subscription = await Webhooks.getSubscription(db, owner, id, { access });
982
1058
  if (!subscription)
983
1059
  return notFound(c);
984
1060
  return c.json(Response.validated(schema.getWebhook.Response, toPublic(subscription)), 200);
@@ -1030,12 +1106,13 @@ export function webhooks(options = {}) {
1030
1106
  const owner = ownerFor(c);
1031
1107
  if (!owner)
1032
1108
  return unauthorized(c);
1109
+ const access = accessFor(c);
1033
1110
  const { id } = c.req.valid('param');
1034
1111
  const { cursor, include, limit, page } = c.req.valid('query');
1035
1112
  try {
1036
1113
  // Owner-scope first: resolving the subscription under the caller's owner
1037
1114
  // key 404s ids they don't own (indistinguishable from "doesn't exist").
1038
- const subscription = await Webhooks.getSubscription(db, owner, id);
1115
+ const subscription = await Webhooks.getSubscription(db, owner, id, { access });
1039
1116
  if (!subscription)
1040
1117
  return notFound(c);
1041
1118
  // `totalCount` is opt-in and exact (the delivery log is TTL-bounded);
@@ -1102,10 +1179,11 @@ export function webhooks(options = {}) {
1102
1179
  const owner = ownerFor(c);
1103
1180
  if (!owner)
1104
1181
  return unauthorized(c);
1182
+ const access = accessFor(c);
1105
1183
  const { id } = c.req.valid('param');
1106
1184
  try {
1107
1185
  // Owner-scope first so unowned/unknown ids are indistinguishable (404).
1108
- const subscription = await Webhooks.getSubscription(db, owner, id);
1186
+ const subscription = await Webhooks.getSubscription(db, owner, id, { access });
1109
1187
  if (!subscription)
1110
1188
  return notFound(c);
1111
1189
  // Inline delivery (not the queue) so the caller gets an immediate
@@ -1159,10 +1237,11 @@ export function webhooks(options = {}) {
1159
1237
  const owner = ownerFor(c);
1160
1238
  if (!owner)
1161
1239
  return unauthorized(c);
1240
+ const access = accessFor(c);
1162
1241
  const { deliveryId, id } = c.req.valid('param');
1163
1242
  try {
1164
1243
  // Owner-scope the subscription first so unowned ids 404 like elsewhere.
1165
- const subscription = await Webhooks.getSubscription(db, owner, id);
1244
+ const subscription = await Webhooks.getSubscription(db, owner, id, { access });
1166
1245
  if (!subscription)
1167
1246
  return notFound(c);
1168
1247
  const delivery = await Webhooks.getDelivery(db, id, deliveryId);
@@ -1240,6 +1319,7 @@ export function webhooks(options = {}) {
1240
1319
  const owner = ownerFor(c);
1241
1320
  if (!owner)
1242
1321
  return unauthorized(c);
1322
+ const access = accessFor(c);
1243
1323
  const { id } = c.req.valid('param');
1244
1324
  const patch = c.req.valid('json');
1245
1325
  try {
@@ -1249,12 +1329,12 @@ export function webhooks(options = {}) {
1249
1329
  // persisting — otherwise a bad filter would silently widen the match
1250
1330
  // set at scan time.
1251
1331
  if (patch.filters !== undefined) {
1252
- const existing = await Webhooks.getSubscription(db, owner, id);
1332
+ const existing = await Webhooks.getSubscription(db, owner, id, { access });
1253
1333
  if (!existing)
1254
1334
  return notFound(c);
1255
1335
  parseFilters(existing.eventType, patch.filters);
1256
1336
  }
1257
- const subscription = await Webhooks.updateSubscription(db, owner, id, patch);
1337
+ const subscription = await Webhooks.updateSubscription(db, owner, id, patch, { access });
1258
1338
  if (!subscription)
1259
1339
  return notFound(c);
1260
1340
  return c.json(Response.validated(schema.updateWebhook.Response, toPublic(subscription)), 200);
@@ -1298,9 +1378,10 @@ export function webhooks(options = {}) {
1298
1378
  const owner = ownerFor(c);
1299
1379
  if (!owner)
1300
1380
  return unauthorized(c);
1381
+ const access = accessFor(c);
1301
1382
  const { id } = c.req.valid('param');
1302
1383
  try {
1303
- const deleted = await Webhooks.deleteSubscription(db, owner, id);
1384
+ const deleted = await Webhooks.deleteSubscription(db, owner, id, { access });
1304
1385
  if (!deleted)
1305
1386
  return notFound(c);
1306
1387
  return c.json(Response.validated(schema.deleteWebhook.Response, { id }), 200);
@@ -1310,459 +1391,6 @@ export function webhooks(options = {}) {
1310
1391
  }
1311
1392
  }));
1312
1393
  }
1313
- /**
1314
- * Largest representable in-block index, used as an end-of-block cursor sentinel
1315
- * so a fully-scanned block advances the cursor strictly past it (no row index
1316
- * can equal it).
1317
- */
1318
- const endOfBlockIndex = 2_147_483_647;
1319
- const scanPageSize = 100;
1320
- /** Concurrent per-subscription scans and cursor writes per poll round; caps RPC and DB fan-out. */
1321
- const pollConcurrency = 8;
1322
- /** Event types scanned together when their combined state fits one existing scan round. */
1323
- const pollEventTypeConcurrency = 2;
1324
- /** Maximum subscription states retained by concurrent event-type scan batches. */
1325
- const pollStateLimit = WebhookTransfer.scanCohortSize * pollConcurrency;
1326
- /** Schedules asynchronous work through one shared concurrency bound. */
1327
- function createLimiter(limit) {
1328
- let active = 0;
1329
- const waiting = [];
1330
- return async (fn) => {
1331
- if (active >= limit)
1332
- await new Promise((resolve) => waiting.push(resolve));
1333
- else
1334
- active++;
1335
- try {
1336
- return await fn();
1337
- }
1338
- finally {
1339
- const next = waiting.shift();
1340
- if (next)
1341
- next();
1342
- else
1343
- active--;
1344
- }
1345
- };
1346
- }
1347
- /** Runs `fn` over `items` with at most `limit` in flight, preserving order. */
1348
- async function mapWithLimit(items, limit, fn) {
1349
- const results = [];
1350
- let next = 0;
1351
- await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
1352
- while (next < items.length) {
1353
- const index = next++;
1354
- try {
1355
- results[index] = { status: 'fulfilled', value: await fn(items[index]) };
1356
- }
1357
- catch (reason) {
1358
- results[index] = { reason, status: 'rejected' };
1359
- }
1360
- }
1361
- }));
1362
- return results;
1363
- }
1364
- /** Returns `candidate` only when it is lexicographically after `current`. */
1365
- function laterCursor(current, candidate) {
1366
- const before = Cursor.decode(current, ['int', 'int']);
1367
- const after = Cursor.decode(candidate, ['int', 'int']);
1368
- if (!after)
1369
- return current;
1370
- if (!before)
1371
- return candidate;
1372
- const [beforeBlock, beforeIndex] = before;
1373
- const [afterBlock, afterIndex] = after;
1374
- return afterBlock > beforeBlock || (afterBlock === beforeBlock && afterIndex > beforeIndex)
1375
- ? candidate
1376
- : current;
1377
- }
1378
- /**
1379
- * Polls the chain over RPC for new events and dispatches a signed envelope per
1380
- * matching row, advancing each active subscription's `(block, index)` keyset
1381
- * cursor.
1382
- *
1383
- * Every event type scans a **narrow confirmed window** `[cursor, head -
1384
- * confirmations]` directly over JSON-RPC (`eth_getLogs`,
1385
- * `eth_getBlockByNumber`) so webhook delivery does not wait for an indexer.
1386
- *
1387
- * The chain head is injectable (tests pin a fixed historical window); only
1388
- * blocks at least `confirmations` behind head are emitted, giving at-least-once,
1389
- * finalized-ish semantics (re-delivery overlap is harmless because the receiver
1390
- * dedupes on the stable `event.id`).
1391
- *
1392
- * Cursor advances only past successfully dispatched rows, so a dispatch failure
1393
- * pauses that subscription at the last good position and the next tick resumes.
1394
- * Scan failures leave that subscription unchanged; the first error is rethrown
1395
- * after the remaining subscriptions run.
1396
- */
1397
- export async function poll(options) {
1398
- const { batchSize = 200, cache: cache_metadata = Store.memory(), chainId, client, confirmations = 1, db, eventTypes: eventTypes_input = [
1399
- 'token:transfer',
1400
- 'transaction:included',
1401
- 'log:emitted',
1402
- 'block:created',
1403
- ], maxBlocks = 1_000, } = options;
1404
- const now = options.now ?? (() => new Date());
1405
- options.signal?.throwIfAborted();
1406
- // Default (self-host) transport: deliver inline and record the outcome so a
1407
- // sustained-failure subscription auto-disables. On Cloudflare, callers pass a
1408
- // Queue producer instead and the consumer runs `deliverAndRecord`.
1409
- const dispatch = options.dispatch ??
1410
- (({ envelope, subscription }) => Webhooks.deliverAndRecord(db, subscription, envelope));
1411
- const dispatchBatch = options.dispatchBatch;
1412
- // A pinned `headBlockNumber` lets a head subscription supply the exact live
1413
- // boundary and keeps historical tests deterministic.
1414
- const override = options.headBlockNumber;
1415
- let rpcHead = override === undefined ? undefined : Number(override);
1416
- const resolveRpcHead = async () => (rpcHead ??= await getRpcHead(client));
1417
- const headStartedAt = Date.now();
1418
- const rpcSafeBlock = await (async () => {
1419
- try {
1420
- const safeBlock = Math.max(0, (await resolveRpcHead()) - confirmations);
1421
- options.onStage?.({
1422
- durationMs: Date.now() - headStartedAt,
1423
- outcome: 'ok',
1424
- stage: 'rpc_head',
1425
- });
1426
- return safeBlock;
1427
- }
1428
- catch (error) {
1429
- options.onStage?.({
1430
- durationMs: Date.now() - headStartedAt,
1431
- outcome: 'error',
1432
- stage: 'rpc_head',
1433
- });
1434
- throw error;
1435
- }
1436
- })();
1437
- const result = {
1438
- chainId,
1439
- dispatched: 0,
1440
- failed: 0,
1441
- safeBlock: rpcSafeBlock,
1442
- scanned: 0,
1443
- subscriptions: 0,
1444
- };
1445
- let cursorLagBlocks = 0;
1446
- const pollErrors = [];
1447
- const eventTypes = [...new Set(eventTypes_input)];
1448
- const subscriptionsStartedAt = Date.now();
1449
- const pairs = await Webhooks.listActiveForChain(db, { chainId, eventTypes });
1450
- options.signal?.throwIfAborted();
1451
- options.onSubscriptions?.(pairs.length);
1452
- options.onStage?.({
1453
- durationMs: Date.now() - subscriptionsStartedAt,
1454
- outcome: 'ok',
1455
- stage: 'subscription_read',
1456
- });
1457
- const pairsByEventType = Map.groupBy(pairs, (pair) => pair.subscription.eventType);
1458
- const eventTypes_active = eventTypes.filter((eventType) => (pairsByEventType.get(eventType)?.length ?? 0) > 0);
1459
- const eventTypeConcurrency = pairs.length <= pollStateLimit
1460
- ? Math.min(pollEventTypeConcurrency, eventTypes_active.length)
1461
- : Math.min(1, eventTypes_active.length);
1462
- const scanRoundSize = Math.floor(pollStateLimit / Math.max(1, eventTypeConcurrency));
1463
- const scheduleScan = createLimiter(pollConcurrency);
1464
- const eventTypeResults = await mapWithLimit(eventTypes_active, eventTypeConcurrency, async (eventType) => {
1465
- options.signal?.throwIfAborted();
1466
- const safeBlock = rpcSafeBlock;
1467
- const pairs = pairsByEventType.get(eventType) ?? [];
1468
- const durations = {
1469
- cursor_write: 0,
1470
- enrich: 0,
1471
- scan: 0,
1472
- };
1473
- const observed = new Set();
1474
- let cursorWriteOutcome = 'ok';
1475
- let scanOutcome = 'ok';
1476
- const writeCursor = async (parameters) => {
1477
- const startedAt = Date.now();
1478
- observed.add('cursor_write');
1479
- try {
1480
- return await Webhooks.compareAndSetCursor(db, parameters);
1481
- }
1482
- catch (error) {
1483
- cursorWriteOutcome = 'error';
1484
- pollErrors.push(error);
1485
- return false;
1486
- }
1487
- finally {
1488
- durations.cursor_write += Date.now() - startedAt;
1489
- }
1490
- };
1491
- let states = [];
1492
- for (const pair of pairs) {
1493
- const { subscription } = pair;
1494
- result.subscriptions++;
1495
- let filters;
1496
- try {
1497
- filters = parseFilters(subscription.eventType, subscription.filters);
1498
- }
1499
- catch (error) {
1500
- if (!(error instanceof Webhooks.InvalidFilterError))
1501
- throw error;
1502
- options.signal?.throwIfAborted();
1503
- // A breaking filter-schema removal must not poison every chain poll.
1504
- // Disable the legacy subscription without widening its match set.
1505
- await Webhooks.updateSubscription(db, subscription.owner, subscription.id, {
1506
- status: 'disabled',
1507
- });
1508
- continue;
1509
- }
1510
- let cursor = pair.cursor;
1511
- if (cursor === null) {
1512
- // Legacy subscriptions without a creation checkpoint anchor at the
1513
- // current confirmed head, avoiding a genesis backfill.
1514
- await writeCursor({
1515
- cursor: Cursor.encode([safeBlock, endOfBlockIndex]),
1516
- expectedCursor: null,
1517
- subscriptionId: subscription.id,
1518
- });
1519
- continue;
1520
- }
1521
- const checkpoint = Cursor.decode(cursor, ['int']);
1522
- if (checkpoint) {
1523
- // Creation checkpoints record the live head without finalizing it. Rebase
1524
- // to this poller's confirmed boundary so replacement blocks stay eligible.
1525
- const confirmedCursor = Cursor.encode([
1526
- Math.max(0, checkpoint[0] - confirmations),
1527
- endOfBlockIndex,
1528
- ]);
1529
- const committed = await writeCursor({
1530
- cursor: confirmedCursor,
1531
- expectedCursor: cursor,
1532
- subscriptionId: subscription.id,
1533
- });
1534
- if (!committed)
1535
- continue;
1536
- cursor = confirmedCursor;
1537
- }
1538
- const position = Cursor.decode(cursor, ['int', 'int']);
1539
- if (position)
1540
- cursorLagBlocks = Math.max(cursorLagBlocks, safeBlock - position[0]);
1541
- const window = scanWindow(cursor, safeBlock, maxBlocks);
1542
- if (window && window.to < safeBlock)
1543
- result.hasMore = true;
1544
- states.push({
1545
- cursor,
1546
- filters,
1547
- remaining: batchSize,
1548
- safeBlock: window?.to ?? safeBlock,
1549
- subscription,
1550
- });
1551
- }
1552
- // Every subscription gets one bounded page before any subscription gets a
1553
- // continuation page, preserving fairness while allowing catch-up.
1554
- while (states.length > 0) {
1555
- options.signal?.throwIfAborted();
1556
- const continuations = [];
1557
- const round = states;
1558
- for (let offset = 0; offset < round.length; offset += scanRoundSize) {
1559
- const batch = round.slice(offset, offset + scanRoundSize);
1560
- const windows = batch.flatMap((state) => {
1561
- const window = scanWindow(state.cursor, state.safeBlock, maxBlocks);
1562
- return window ? [window] : [];
1563
- });
1564
- const maxWindowBlocks = Math.max(0, ...windows.map((window) => window.to - window.from + 1));
1565
- const totalWindowBlocks = windows.reduce((total, window) => total + window.to - window.from + 1, 0);
1566
- options.onScanCheckpoint?.({
1567
- concurrency: pollConcurrency,
1568
- eventType,
1569
- maxWindowBlocks,
1570
- phase: 'start',
1571
- rpcMethod: scanRpcMethod(eventType),
1572
- subscriptionLimit: scanRoundSize,
1573
- subscriptionCount: batch.length,
1574
- totalWindowBlocks,
1575
- });
1576
- const scanStartedAt = Date.now();
1577
- const pages = await scanSubscriptions({
1578
- client,
1579
- eventType,
1580
- maxBlocks,
1581
- schedule: scheduleScan,
1582
- states: batch,
1583
- });
1584
- options.onScanCheckpoint?.({
1585
- concurrency: pollConcurrency,
1586
- durationMs: Date.now() - scanStartedAt,
1587
- eventType,
1588
- failedSubscriptions: pages.filter((page) => page.status === 'rejected').length,
1589
- matchedItems: pages.reduce((total, page) => total + (page.status === 'fulfilled' ? page.value.items.length : 0), 0),
1590
- maxWindowBlocks,
1591
- outcome: scanResultOutcome(pages),
1592
- phase: 'complete',
1593
- rpcMethod: scanRpcMethod(eventType),
1594
- subscriptionLimit: scanRoundSize,
1595
- subscriptionCount: batch.length,
1596
- totalWindowBlocks,
1597
- });
1598
- options.signal?.throwIfAborted();
1599
- durations.scan += Date.now() - scanStartedAt;
1600
- observed.add('scan');
1601
- if (pages.some((page) => page.status === 'rejected'))
1602
- scanOutcome = 'error';
1603
- const busy = [];
1604
- const idle = [];
1605
- for (let i = 0; i < batch.length; i++) {
1606
- const state = batch[i];
1607
- const settled = pages[i];
1608
- if (settled.status === 'rejected') {
1609
- pollErrors.push(settled.reason);
1610
- continue;
1611
- }
1612
- result.scanned += settled.value.items.length;
1613
- state.remaining -= settled.value.items.length;
1614
- (settled.value.items.length > 0 ? busy : idle).push({ page: settled.value, state });
1615
- }
1616
- // Empty pages only advance their cursor: no dispatch order to preserve,
1617
- // so one statement commits the cohort while retaining per-row CAS semantics.
1618
- const cursorWritesStartedAt = Date.now();
1619
- const candidates = idle.flatMap(({ page, state }) => {
1620
- options.signal?.throwIfAborted();
1621
- const candidate = page.scannedThrough !== undefined
1622
- ? Cursor.encode([page.scannedThrough, endOfBlockIndex])
1623
- : page.hasMore
1624
- ? undefined
1625
- : Cursor.encode([state.safeBlock, endOfBlockIndex]);
1626
- const cursor = candidate ? laterCursor(state.cursor, candidate) : state.cursor;
1627
- return cursor === state.cursor ? [] : [{ cursor, state }];
1628
- });
1629
- if (candidates.length > 0) {
1630
- const committed = await (async () => {
1631
- try {
1632
- return await Webhooks.compareAndSetCursors(db, {
1633
- cursors: candidates.map(({ cursor, state }) => ({
1634
- cursor,
1635
- expectedCursor: state.cursor,
1636
- subscriptionId: state.subscription.id,
1637
- })),
1638
- });
1639
- }
1640
- catch (error) {
1641
- cursorWriteOutcome = 'error';
1642
- pollErrors.push(error);
1643
- return new Set();
1644
- }
1645
- })();
1646
- for (const { cursor, state } of candidates)
1647
- if (committed.has(state.subscription.id))
1648
- state.cursor = cursor;
1649
- durations.cursor_write += Date.now() - cursorWritesStartedAt;
1650
- observed.add('cursor_write');
1651
- }
1652
- for (const { page, state } of busy) {
1653
- options.signal?.throwIfAborted();
1654
- const { subscription } = state;
1655
- let accepted;
1656
- let failed = false;
1657
- const buildDispatchable = async (item) => {
1658
- options.signal?.throwIfAborted();
1659
- // Token metadata is best-effort for webhooks: transfers use it for display,
1660
- // while transactions expose fee-token references without blocking delivery.
1661
- const enrichStartedAt = Date.now();
1662
- const data = await enrichEventData(eventType, item.data, {
1663
- cache: cache_metadata,
1664
- chainId,
1665
- client,
1666
- });
1667
- durations.enrich += Date.now() - enrichStartedAt;
1668
- observed.add('enrich');
1669
- options.signal?.throwIfAborted();
1670
- const envelope = Webhooks.buildEnvelope({
1671
- blockNumber: item.cursor[0],
1672
- createdAt: now(),
1673
- data,
1674
- logIndex: item.cursor[1],
1675
- subscription,
1676
- });
1677
- return { envelope, subscription };
1678
- };
1679
- if (dispatchBatch) {
1680
- const dispatchables = [];
1681
- for (const item of page.items)
1682
- dispatchables.push(await buildDispatchable(item));
1683
- options.signal?.throwIfAborted();
1684
- const delivered = await dispatchBatch(dispatchables, { db });
1685
- if (delivered.ok) {
1686
- accepted = page.items.at(-1)?.cursor;
1687
- result.dispatched += dispatchables.length;
1688
- }
1689
- else {
1690
- result.failed += dispatchables.length;
1691
- failed = true;
1692
- }
1693
- }
1694
- else {
1695
- for (const item of page.items) {
1696
- const dispatchable = await buildDispatchable(item);
1697
- const delivered = await dispatch(dispatchable);
1698
- if (!delivered.ok) {
1699
- result.failed++;
1700
- failed = true;
1701
- break;
1702
- }
1703
- accepted = item.cursor;
1704
- result.dispatched++;
1705
- }
1706
- }
1707
- // Persist every accepted page before another subscription begins its
1708
- // continuation, so a later scan failure retains completed work.
1709
- const candidate = (() => {
1710
- if (failed)
1711
- return accepted ? Cursor.encode([...accepted]) : undefined;
1712
- if (page.scannedThrough !== undefined)
1713
- return Cursor.encode([page.scannedThrough, endOfBlockIndex]);
1714
- if (accepted)
1715
- return Cursor.encode([...accepted]);
1716
- if (!page.hasMore)
1717
- return Cursor.encode([state.safeBlock, endOfBlockIndex]);
1718
- return undefined;
1719
- })();
1720
- const cursor = candidate ? laterCursor(state.cursor, candidate) : state.cursor;
1721
- const progressed = cursor !== state.cursor;
1722
- let committed = false;
1723
- if (progressed) {
1724
- committed = await writeCursor({
1725
- cursor,
1726
- expectedCursor: state.cursor,
1727
- subscriptionId: subscription.id,
1728
- });
1729
- if (committed)
1730
- state.cursor = cursor;
1731
- }
1732
- if (!failed && page.hasMore === true) {
1733
- if (committed && state.remaining > 0)
1734
- continuations.push(state);
1735
- else
1736
- result.hasMore = true;
1737
- }
1738
- }
1739
- }
1740
- states = continuations;
1741
- }
1742
- for (const stage of observed) {
1743
- const outcome = (() => {
1744
- if (stage === 'cursor_write')
1745
- return cursorWriteOutcome;
1746
- if (stage === 'scan')
1747
- return scanOutcome;
1748
- return 'ok';
1749
- })();
1750
- options.onStage?.({
1751
- durationMs: durations[stage],
1752
- eventType,
1753
- outcome,
1754
- stage,
1755
- });
1756
- }
1757
- });
1758
- for (const result of eventTypeResults)
1759
- if (result.status === 'rejected')
1760
- pollErrors.push(result.reason);
1761
- options.onCursorLag?.(cursorLagBlocks);
1762
- if (pollErrors.length > 0)
1763
- throw pollErrors[0];
1764
- return result;
1765
- }
1766
1394
  /**
1767
1395
  * Attaches best-effort `{ symbol, decimals }` token metadata to a
1768
1396
  * `token:transfer` row so destinations can render human amounts. Reads the row's
@@ -1849,62 +1477,9 @@ async function withFeeTokenMetadata(data, deps) {
1849
1477
  export async function getRpcHead(client) {
1850
1478
  return Number(await client.getBlockNumber());
1851
1479
  }
1852
- /** Groups aligned cursors only when their bounded upstream scans are equivalent. */
1853
- async function scanSubscriptions(options) {
1854
- const { client, eventType, maxBlocks, schedule, states } = options;
1855
- if (eventType !== 'token:transfer')
1856
- return scanIndependent({ client, eventType, maxBlocks, schedule, states });
1857
- return WebhookTransfer.scan({
1858
- client,
1859
- concurrency: pollConcurrency,
1860
- maxBlocks,
1861
- schedule,
1862
- states: states.map((state) => ({
1863
- filters: state.filters,
1864
- limit: Math.min(scanPageSize, state.remaining),
1865
- window: scanWindow(state.cursor, state.safeBlock, maxBlocks),
1866
- })),
1867
- });
1868
- }
1869
- /** Returns the bounded JSON-RPC method used by an event scanner. */
1870
- function scanRpcMethod(eventType) {
1871
- return eventType === 'block:created' || eventType === 'transaction:included'
1872
- ? 'eth_getBlockByNumber'
1873
- : 'eth_getLogs';
1874
- }
1875
- /** Classifies a completed batch without exposing an unbounded upstream error. */
1876
- function scanResultOutcome(pages) {
1877
- const rejected = pages.filter((page) => page.status === 'rejected');
1878
- if (rejected.some((page) => page.reason instanceof BlockUnavailableError))
1879
- return 'missing';
1880
- if (rejected.length > 0)
1881
- return 'error';
1882
- return pages.every((page) => page.status === 'fulfilled' && page.value.items.length === 0)
1883
- ? 'empty'
1884
- : 'ok';
1885
- }
1886
- /** Scans CPU-matched streams per subscription while sharing concurrent block reads. */
1887
- function scanIndependent(options) {
1888
- const { client, eventType, maxBlocks, schedule, states } = options;
1889
- const cache = createChainCache(client);
1890
- return mapWithLimit(states, pollConcurrency, (state) => schedule(() => {
1891
- const shared = {
1892
- batchSize: Math.min(scanPageSize, state.remaining),
1893
- cache,
1894
- cursor: state.cursor,
1895
- filters: state.filters,
1896
- maxBlocks,
1897
- safeBlock: state.safeBlock,
1898
- };
1899
- if (eventType === 'block:created')
1900
- return scanBlocks(shared);
1901
- if (eventType === 'log:emitted')
1902
- return scanLogs({ ...shared, client });
1903
- return scanTxs(shared);
1904
- }));
1905
- }
1906
1480
  const filterSchemas = {
1907
1481
  'block:created': schema.BlockFilters,
1482
+ 'funding:deposit.updated': schema.FundingDepositFilters,
1908
1483
  'log:emitted': schema.LogFilters,
1909
1484
  'token:transfer': schema.TransferFilters,
1910
1485
  'transaction:included': schema.TxFilters,
@@ -1912,7 +1487,7 @@ const filterSchemas = {
1912
1487
  /**
1913
1488
  * Validates `filters` against the schema for `eventType`, **failing closed**: an
1914
1489
  * invalid filter throws {@link Webhooks.InvalidFilterError} instead of degrading
1915
- * to `{}` (a firehose). Used at create/patch time and by the scanners so a stored
1490
+ * to `{}` (a firehose). Used at create/patch time and by {@link scanBlock} so a stored
1916
1491
  * bad filter can never silently widen the match set.
1917
1492
  */
1918
1493
  export function parseFilters(eventType, filters) {
@@ -1962,7 +1537,7 @@ function operandEquals(value, target, kind) {
1962
1537
  * The JS counterpart of the old SQL `applyOperator`: evaluates one parsed filter
1963
1538
  * operator (from {@link schema.eq}/{@link schema.compare}/{@link schema.bytes})
1964
1539
  * against a single `0x`-hex field value pulled from an RPC object. The one place
1965
- * operator semantics live, shared by every RPC scanner. Returns `false` for a
1540
+ * operator semantics live, shared by every event matcher. Returns `false` for a
1966
1541
  * missing field so an absent value never matches (fail-closed).
1967
1542
  *
1968
1543
  * - bare value / `{ eq }` → equality
@@ -2021,74 +1596,10 @@ function matchesCalls(calls, op) {
2021
1596
  }
2022
1597
  return has(op);
2023
1598
  }
2024
- // RPC window helpers + per-tick lazy cache.
2025
- /** Renders a block number as a `0x`-hex tag for JSON-RPC params. */
2026
- const blockTag = (n) => `0x${n.toString(16)}`;
2027
- /** Issues a JSON-RPC request through the viem client for a method viem can't type. */
2028
- function rpc(client, method, params) {
2029
- return client.request({
2030
- method,
2031
- params: [...params],
2032
- });
2033
- }
2034
1599
  /**
2035
- * The confirmed scan window for a cursor. A cursor `(cb, ci)` means rows through
2036
- * `(cb, ci)` were dispatched: when `ci` is the end-of-block sentinel the whole
2037
- * block `cb` is done and scanning resumes at `cb + 1`; otherwise block `cb` is
2038
- * re-scanned (skipping indices `<= ci`). `to` is capped by both `safeBlock` and
2039
- * the per-tick backfill cap. Returns `undefined` when the subscription is
2040
- * already caught up (nothing to scan, cursor unchanged).
2041
- */
2042
- function scanWindow(cursor, safeBlock, maxBlocks) {
2043
- const decoded = cursor ? Cursor.decode(cursor, ['int', 'int']) : undefined;
2044
- const cb = decoded ? decoded[0] : 0;
2045
- const ci = decoded ? decoded[1] : endOfBlockIndex;
2046
- const from = ci >= endOfBlockIndex ? cb + 1 : cb;
2047
- if (from > safeBlock)
2048
- return undefined;
2049
- const to = Math.min(safeBlock, from + maxBlocks - 1);
2050
- return { from, skip: [cb, ci], to };
2051
- }
2052
- /** True when `(block, index)` is at or before the already-dispatched cursor. */
2053
- function atOrBefore(block, index, skip) {
2054
- return block < skip[0] || (block === skip[0] && index <= skip[1]);
2055
- }
2056
- /** Builds a per-batch {@link ChainCache} bound to a viem client. */
2057
- function createChainCache(client) {
2058
- const headers = new Map();
2059
- const blocks = new Map();
2060
- const singleFlight = (pending, key, load) => {
2061
- const existing = pending.get(key);
2062
- if (existing)
2063
- return existing;
2064
- const promise = load().finally(() => pending.delete(key));
2065
- pending.set(key, promise);
2066
- return promise;
2067
- };
2068
- return {
2069
- block(n) {
2070
- return singleFlight(blocks, n, () =>
2071
- // The transaction payload reuses `humanizeTransaction`, which consumes
2072
- // the raw RPC tx shape, so the full block stays on the untyped path.
2073
- rpc(client, 'eth_getBlockByNumber', [
2074
- blockTag(n),
2075
- true,
2076
- ]).then((block) => {
2077
- if (!block)
2078
- throw new BlockUnavailableError(n);
2079
- return block;
2080
- }));
2081
- },
2082
- header(n) {
2083
- return singleFlight(headers, n, () => client.getBlock({ blockNumber: BigInt(n), includeTransactions: false }));
2084
- },
2085
- };
2086
- }
2087
- /**
2088
- * Builds the delivered `log:emitted` payload from a viem-formatted log plus the
1600
+ * Builds the delivered `log:emitted` payload from a viem-shaped log plus the
2089
1601
  * block timestamp. Returns `undefined` when a required field is missing/invalid
2090
- * (the row is skipped). When a `signature`/`event` is supplied, `getLogs` has
2091
- * already decoded `args`/`eventName`, so this just serializes them.
1602
+ * (the row is skipped). Decoded `args`/`eventName` are serialized when present.
2092
1603
  */
2093
1604
  function toLogEvent(log, timestamp, signature, event) {
2094
1605
  const address = Schema.Address.safeParse(log.address);
@@ -2153,7 +1664,7 @@ function pushHex(op) {
2153
1664
  return value;
2154
1665
  return undefined;
2155
1666
  }
2156
- /** Narrows an `eq`/`in` address operator into the `getLogs` address pushdown. */
1667
+ /** Narrows an `eq`/`in` address operator into a pushdown-capable address value. */
2157
1668
  function pushAddress(op) {
2158
1669
  return pushHex(op);
2159
1670
  }
@@ -2169,7 +1680,7 @@ function parseEvent(signature) {
2169
1680
  return undefined;
2170
1681
  }
2171
1682
  }
2172
- /** Returns exact indexed arguments that viem can push into `eth_getLogs`. */
1683
+ /** Returns exact indexed arguments usable as a selective log anchor. */
2173
1684
  function pushEventArgs(args, event) {
2174
1685
  const pushed = {};
2175
1686
  for (const [name, op] of Object.entries(args ?? {})) {
@@ -2180,7 +1691,7 @@ function pushEventArgs(args, event) {
2180
1691
  }
2181
1692
  return pushed;
2182
1693
  }
2183
- /** Checks that the accepted filter has a selective `eth_getLogs` representation. */
1694
+ /** Checks that the accepted filter carries a selective anchor rather than matching every log. */
2184
1695
  function hasLogPushdown(f) {
2185
1696
  if (pushAddress(f.address) !== undefined)
2186
1697
  return true;
@@ -2190,19 +1701,7 @@ function hasLogPushdown(f) {
2190
1701
  return secondaryTopic || Object.keys(pushEventArgs(f.args, event)).length > 0;
2191
1702
  return pushHex(f.topic0) !== undefined && secondaryTopic;
2192
1703
  }
2193
- /** Builds raw topic filters, preserving null wildcards between constrained positions. */
2194
- function pushTopics(f, event) {
2195
- const topics = [
2196
- event ? encodeEventTopics({ abi: [event] })[0] : pushHex(f.topic0),
2197
- pushHex(f.topic1),
2198
- pushHex(f.topic2),
2199
- pushHex(f.topic3),
2200
- ];
2201
- while (topics.length > 0 && topics.at(-1) === undefined)
2202
- topics.pop();
2203
- return topics.map((topic) => topic ?? null);
2204
- }
2205
- /** Converts one raw JSON-RPC log into the viem-shaped scanner input. */
1704
+ /** Converts one raw JSON-RPC log into the viem-shaped matcher input. */
2206
1705
  function fromRpcLog(log) {
2207
1706
  return {
2208
1707
  address: log.address,
@@ -2244,64 +1743,6 @@ export async function enrichEventData(eventType, data, options) {
2244
1743
  return withFeeTokenMetadata(data, options);
2245
1744
  return data;
2246
1745
  }
2247
- /** Scans contract logs for one subscription, then re-checks every filter in memory. */
2248
- async function scanLogs(options) {
2249
- const { batchSize, cache, client, cursor, filters, maxBlocks, safeBlock } = options;
2250
- const f = filters;
2251
- const window = scanWindow(cursor, safeBlock, maxBlocks);
2252
- if (!window)
2253
- return { items: [], scannedThrough: undefined };
2254
- const event = parseEvent(f.signature);
2255
- if (f.signature && (!event || !/^[A-Za-z_]\w*$/.test(event.name)))
2256
- throw new Webhooks.InvalidFilterError('log:emitted', [
2257
- { message: 'Unsupported event signature.', path: ['signature'] },
2258
- ]);
2259
- const address = pushAddress(f.address);
2260
- const topics = pushTopics(f, event);
2261
- const hasRawTopics = topics.slice(1).some((topic) => topic !== null);
2262
- const args = event ? pushEventArgs(f.args, event) : {};
2263
- const logs = (!event || hasRawTopics
2264
- ? (await rpc(client, 'eth_getLogs', [
2265
- {
2266
- ...(address ? { address } : {}),
2267
- fromBlock: blockTag(window.from),
2268
- ...(topics.length > 0 ? { topics } : {}),
2269
- toBlock: blockTag(window.to),
2270
- },
2271
- ]))
2272
- .map(fromRpcLog)
2273
- .map((log) => (event ? decodeScanLog(log, event) : log))
2274
- : await client.getLogs({
2275
- ...(address ? { address } : {}),
2276
- ...(Object.keys(args).length > 0 ? { args: args } : {}),
2277
- event,
2278
- fromBlock: BigInt(window.from),
2279
- strict: false,
2280
- toBlock: BigInt(window.to),
2281
- }));
2282
- logs.sort((a, b) => {
2283
- const block = Number(a.blockNumber ?? 0n) - Number(b.blockNumber ?? 0n);
2284
- return block || (a.logIndex ?? 0) - (b.logIndex ?? 0);
2285
- });
2286
- const items = [];
2287
- for (const log of logs) {
2288
- const blockNumber = log.blockNumber === null ? undefined : Number(log.blockNumber);
2289
- const logIndex = log.logIndex ?? undefined;
2290
- if (blockNumber === undefined || logIndex === undefined)
2291
- continue;
2292
- if (atOrBefore(blockNumber, logIndex, window.skip))
2293
- continue;
2294
- if (!matchesLog(log, f, event))
2295
- continue;
2296
- const data = toLogEvent(log, Value.blockToIso(await cache.header(blockNumber)), f.signature, event);
2297
- if (data) {
2298
- items.push({ cursor: [data.blockNumber, data.logIndex], data });
2299
- if (items.length >= batchSize)
2300
- return { hasMore: true, items };
2301
- }
2302
- }
2303
- return { items, scannedThrough: window.to };
2304
- }
2305
1746
  /** Re-checks all log filters (including non-pushable operators) in JS. */
2306
1747
  function matchesLog(log, f, event) {
2307
1748
  const topics = log.topics;
@@ -2315,8 +1756,8 @@ function matchesLog(log, f, event) {
2315
1756
  if (f.blockNumber !== undefined &&
2316
1757
  !matchesOperator(log.blockNumber?.toString(), f.blockNumber, 'number'))
2317
1758
  return false;
2318
- // `getLogs(event)` already anchors topic0 on the event selector, so the raw
2319
- // `topic0` operator only applies in raw (no-signature) mode.
1759
+ // With a signature, topic0 is anchored on the event selector above, so the
1760
+ // raw `topic0` operator only applies in raw (no-signature) mode.
2320
1761
  if (!event && f.topic0 !== undefined && !matchesOperator(topics[0], f.topic0))
2321
1762
  return false;
2322
1763
  if (f.topic1 !== undefined && !matchesOperator(topics[1], f.topic1))
@@ -2326,7 +1767,7 @@ function matchesLog(log, f, event) {
2326
1767
  if (f.topic3 !== undefined && !matchesOperator(topics[3], f.topic3))
2327
1768
  return false;
2328
1769
  if (event && f.args && Object.keys(f.args).length > 0) {
2329
- // `getLogs(event, strict:false)` keeps non-decodable matches with no `args`.
1770
+ // Best-effort decoding leaves a non-decodable log without `args`.
2330
1771
  if (log.args === undefined || Array.isArray(log.args))
2331
1772
  return false;
2332
1773
  const args = log.args;
@@ -2374,31 +1815,6 @@ function toBlockEvent(block) {
2374
1815
  transactionCount,
2375
1816
  };
2376
1817
  }
2377
- /** Scans new blocks over one subscription's confirmed window. */
2378
- async function scanBlocks(options) {
2379
- const { batchSize, cache, cursor, filters, maxBlocks, safeBlock } = options;
2380
- const f = filters;
2381
- const window = scanWindow(cursor, safeBlock, maxBlocks);
2382
- if (!window)
2383
- return { items: [], scannedThrough: undefined };
2384
- const items = [];
2385
- for (let blockNumber = window.from; blockNumber <= window.to; blockNumber++) {
2386
- if (atOrBefore(blockNumber, 0, window.skip))
2387
- continue;
2388
- const block = await cache.header(blockNumber);
2389
- if (!block)
2390
- continue;
2391
- if (!matchesBlock(block, f))
2392
- continue;
2393
- const data = toBlockEvent(block);
2394
- if (data) {
2395
- items.push({ cursor: [data.number, 0], data });
2396
- if (items.length >= batchSize)
2397
- return { hasMore: true, items };
2398
- }
2399
- }
2400
- return { items, scannedThrough: window.to };
2401
- }
2402
1818
  /** Re-checks all block filters in JS. The RPC has no consensus `proposer`. */
2403
1819
  function matchesBlock(block, f) {
2404
1820
  if (f.number !== undefined && !matchesOperator(block.number?.toString(), f.number, 'number'))
@@ -2418,7 +1834,7 @@ function matchesBlock(block, f) {
2418
1834
  return false;
2419
1835
  return true;
2420
1836
  }
2421
- // transaction:included (eth_getBlockByNumber)
1837
+ // transaction:included
2422
1838
  /** Maps a `transaction:included` scalar filter field to its raw-tx field + kind. */
2423
1839
  const txFields = {
2424
1840
  blockNumber: { field: 'blockNumber', kind: 'number' },
@@ -2434,43 +1850,6 @@ const txFields = {
2434
1850
  validBefore: { field: 'validBefore', kind: 'number' },
2435
1851
  value: { field: 'value', kind: 'number' },
2436
1852
  };
2437
- /** Scans included transactions over one subscription's confirmed window. */
2438
- async function scanTxs(options) {
2439
- const { batchSize, cache, cursor, filters, maxBlocks, safeBlock } = options;
2440
- const f = filters;
2441
- const window = scanWindow(cursor, safeBlock, maxBlocks);
2442
- if (!window)
2443
- return { items: [], scannedThrough: undefined };
2444
- const includeCalls = f.includeCalls ?? true;
2445
- const items = [];
2446
- for (let blockNumber = window.from; blockNumber <= window.to; blockNumber++) {
2447
- const block = await cache.block(blockNumber);
2448
- const transactions = Array.isArray(block['transactions'])
2449
- ? block['transactions']
2450
- : [];
2451
- const timestamp = asString(block['timestamp']);
2452
- // A transaction's own `blockTimestamp` is seconds-only, so the delivered
2453
- // timestamp comes from the enclosing block, which carries milliseconds.
2454
- const blockTime = Value.blockToIso(block);
2455
- for (let offset = 0; offset < transactions.length; offset++) {
2456
- const transaction = transactions[offset];
2457
- const transactionIndex = Value.hexToNumber(transaction['transactionIndex']) ?? offset;
2458
- if (atOrBefore(blockNumber, transactionIndex, window.skip))
2459
- continue;
2460
- if (!matchesTx(transaction, f, includeCalls, timestamp, blockNumber))
2461
- continue;
2462
- const parsed = Transactions.schema.Rpc.Transaction.safeParse(transaction);
2463
- if (!parsed.success)
2464
- continue;
2465
- const humanized = Transactions.humanizeTransaction(parsed.data);
2466
- const data = blockTime === undefined ? humanized : { ...humanized, timestamp: blockTime };
2467
- items.push({ cursor: [blockNumber, transactionIndex], data });
2468
- if (items.length >= batchSize)
2469
- return { hasMore: true, items };
2470
- }
2471
- }
2472
- return { items, scannedThrough: window.to };
2473
- }
2474
1853
  /** Re-checks transaction filters against the raw RPC transaction. */
2475
1854
  function matchesTx(tx, f, includeCalls, timestamp, blockNumber) {
2476
1855
  const from = tx['from'];
@@ -2524,6 +1903,16 @@ function ownerFor(c) {
2524
1903
  const payer = principal.payment?.payer;
2525
1904
  return payer ? { payer, type: 'mpp' } : null;
2526
1905
  }
1906
+ function accessFor(c) {
1907
+ const principal = Auth.getPrincipal(c);
1908
+ if (principal?.type !== 'api_key')
1909
+ throw new Error('Webhook routes require an API key.');
1910
+ return {
1911
+ environment: principal.environment,
1912
+ ...(principal.projectId === undefined ? {} : { projectId: principal.projectId }),
1913
+ scopes: principal.apiKey.scopes,
1914
+ };
1915
+ }
2527
1916
  /** Strips internal fields (`owner`, `secret`) from a subscription for reads. */
2528
1917
  function toPublic(subscription) {
2529
1918
  return {
@@ -2531,6 +1920,10 @@ function toPublic(subscription) {
2531
1920
  ...(subscription.context === undefined ? {} : { context: subscription.context }),
2532
1921
  createdAt: subscription.createdAt,
2533
1922
  destination: redactDestination(subscription.destination),
1923
+ ...(subscription.eventType === 'funding:deposit.updated' &&
1924
+ subscription.environment !== undefined
1925
+ ? { environment: subscription.environment }
1926
+ : {}),
2534
1927
  eventType: subscription.eventType,
2535
1928
  ...(subscription.expiresAt === undefined ? {} : { expiresAt: subscription.expiresAt }),
2536
1929
  failureCount: subscription.failureCount,
@@ -2762,10 +2155,4 @@ function toScanHeader(block) {
2762
2155
  timestamp: quantity(block['timestamp']),
2763
2156
  };
2764
2157
  }
2765
- class BlockUnavailableError extends Error {
2766
- name = 'Webhooks.BlockUnavailableError';
2767
- constructor(blockNumber) {
2768
- super(`Block ${blockNumber} is unavailable.`);
2769
- }
2770
- }
2771
2158
  //# sourceMappingURL=webhooks.js.map