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.
- package/dist/App.d.ts +3412 -1968
- package/dist/App.d.ts.map +1 -1
- package/dist/App.js.map +1 -1
- package/dist/Webhooks.d.ts +0 -75
- package/dist/Webhooks.d.ts.map +1 -1
- package/dist/Webhooks.js +1 -143
- package/dist/Webhooks.js.map +1 -1
- package/dist/admin/App.d.ts +74 -74
- package/dist/admin/apps/api-keys.d.ts +4 -4
- package/dist/admin/apps/earn-vaults.d.ts +1 -1
- package/dist/admin/apps/organizations.d.ts +3 -3
- package/dist/admin/apps/verified-tokens.d.ts +74 -74
- package/dist/apps/data/App.d.ts +3618 -2954
- package/dist/apps/data/App.d.ts.map +1 -1
- package/dist/apps/data/App.js +5 -2
- package/dist/apps/data/App.js.map +1 -1
- package/dist/apps/data/routes/activities.d.ts +1 -1
- package/dist/apps/data/routes/activities.d.ts.map +1 -1
- package/dist/apps/data/routes/activities.js +55 -27
- package/dist/apps/data/routes/activities.js.map +1 -1
- package/dist/apps/data/routes/addresses.d.ts +343 -343
- package/dist/apps/data/routes/balances.d.ts +11 -11
- package/dist/apps/data/routes/blocks.d.ts +5 -5
- package/dist/apps/data/routes/coingecko.d.ts +176 -176
- package/dist/apps/data/routes/earn.d.ts +4 -4
- package/dist/apps/data/routes/exchanges.d.ts +4 -4
- package/dist/apps/data/routes/fee-amm.d.ts +26 -26
- package/dist/apps/data/routes/indexer.d.ts +34 -34
- package/dist/apps/data/routes/receipts.d.ts +236 -236
- package/dist/apps/data/routes/tokenlist.d.ts +12 -12
- package/dist/apps/data/routes/tokens.d.ts +3 -3
- package/dist/apps/data/routes/transactions.d.ts +1 -1
- package/dist/apps/data/routes/transfers.d.ts +13 -13
- package/dist/apps/data/routes/valuation.d.ts +21 -21
- package/dist/apps/data/routes/verified-tokens.d.ts +5 -5
- package/dist/apps/data/routes/webhooks.d.ts +1011 -151
- package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
- package/dist/apps/data/routes/webhooks.js +124 -737
- package/dist/apps/data/routes/webhooks.js.map +1 -1
- package/dist/apps/data/routes/zones.d.ts +31 -31
- package/dist/apps/funding/routes/deposit-addresses.d.ts +2 -2
- package/dist/apps/funding/routes/providers.d.ts +13 -13
- package/dist/apps/funding/routes/transfers.d.ts +33 -33
- package/dist/apps/management/App.d.ts +793 -16
- package/dist/apps/management/App.d.ts.map +1 -1
- package/dist/apps/management/routes/api-keys.d.ts +50 -50
- package/dist/apps/management/routes/faucet.d.ts +31 -31
- package/dist/apps/management/routes/invite-links.d.ts +42 -42
- package/dist/apps/management/routes/members.d.ts +79 -79
- package/dist/apps/management/routes/projects.d.ts +40 -40
- package/dist/apps/management/routes/usage.d.ts +80 -80
- package/dist/apps/management/routes/webhooks.d.ts +1031 -59
- package/dist/apps/management/routes/webhooks.d.ts.map +1 -1
- package/dist/apps/management/routes/webhooks.js +27 -4
- package/dist/apps/management/routes/webhooks.js.map +1 -1
- package/dist/db/Db.d.ts.map +1 -1
- package/dist/db/Db.js +21 -0
- package/dist/db/Db.js.map +1 -1
- package/dist/db/Schema.d.ts +7 -1
- package/dist/db/Schema.d.ts.map +1 -1
- package/dist/db/Schema.js +9 -0
- package/dist/db/Schema.js.map +1 -1
- package/dist/db/tables/webhookQueueEvents.d.ts +3 -1
- package/dist/db/tables/webhookQueueEvents.d.ts.map +1 -1
- package/dist/db/tables/webhookQueueEvents.js +4 -0
- package/dist/db/tables/webhookQueueEvents.js.map +1 -1
- package/dist/db/tables/webhookSubscriptions.d.ts +33 -69
- package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -1
- package/dist/db/tables/webhookSubscriptions.js +63 -100
- package/dist/db/tables/webhookSubscriptions.js.map +1 -1
- package/dist/internal/MetricSink.d.ts +35 -125
- package/dist/internal/MetricSink.d.ts.map +1 -1
- package/dist/internal/MetricSink.js +50 -121
- package/dist/internal/MetricSink.js.map +1 -1
- package/dist/internal/Schema.d.ts +3 -1
- package/dist/internal/Schema.d.ts.map +1 -1
- package/dist/internal/Schema.js +4 -2
- package/dist/internal/Schema.js.map +1 -1
- package/dist/internal/WebhookTransfer.d.ts +1 -55
- package/dist/internal/WebhookTransfer.d.ts.map +1 -1
- package/dist/internal/WebhookTransfer.js +1 -239
- package/dist/internal/WebhookTransfer.js.map +1 -1
- package/dist/internal/Webhooks.d.ts +83 -44
- package/dist/internal/Webhooks.d.ts.map +1 -1
- package/dist/internal/Webhooks.js +87 -73
- package/dist/internal/Webhooks.js.map +1 -1
- package/dist/internal/funding/DepositWebhook.d.ts +25 -0
- package/dist/internal/funding/DepositWebhook.d.ts.map +1 -0
- package/dist/internal/funding/DepositWebhook.js +74 -0
- package/dist/internal/funding/DepositWebhook.js.map +1 -0
- package/dist/internal/funding/Reconciliation.d.ts +6 -0
- package/dist/internal/funding/Reconciliation.d.ts.map +1 -1
- package/dist/internal/funding/Reconciliation.js +42 -10
- package/dist/internal/funding/Reconciliation.js.map +1 -1
- package/dist/internal/funding/index.d.ts +1 -0
- package/dist/internal/funding/index.d.ts.map +1 -1
- package/dist/internal/funding/index.js +1 -0
- package/dist/internal/funding/index.js.map +1 -1
- package/dist/internal/webhookDestinations/slack.d.ts.map +1 -1
- package/dist/internal/webhookDestinations/slack.js +16 -0
- package/dist/internal/webhookDestinations/slack.js.map +1 -1
- package/package.json +1 -1
- package/src/App.ts +3 -0
- package/src/Client.test-d.ts +22 -1
- package/src/Webhooks.ts +1 -220
- package/src/apps/data/App.ts +8 -2
- package/src/apps/data/routes/activities.test.ts +107 -11
- package/src/apps/data/routes/activities.ts +64 -32
- package/src/apps/data/routes/webhooks.test.ts +179 -1497
- package/src/apps/data/routes/webhooks.ts +150 -974
- package/src/apps/management/routes/webhooks.test.ts +79 -1
- package/src/apps/management/routes/webhooks.ts +40 -4
- package/src/db/Db.test.ts +1 -5
- package/src/db/Db.ts +23 -0
- package/src/db/Schema.ts +13 -0
- package/src/db/tables/organizations.test.ts +5 -2
- package/src/db/tables/webhookQueueEvents.ts +7 -1
- package/src/db/tables/webhookSubscriptions.ts +89 -138
- package/src/internal/MetricSink.test.ts +41 -373
- package/src/internal/MetricSink.ts +92 -273
- package/src/internal/Schema.test.ts +18 -0
- package/src/internal/Schema.ts +13 -2
- package/src/internal/WebhookTransfer.ts +1 -351
- package/src/internal/Webhooks.test.ts +102 -139
- package/src/internal/Webhooks.ts +175 -106
- package/src/internal/funding/DepositWebhook.test.ts +131 -0
- package/src/internal/funding/DepositWebhook.ts +101 -0
- package/src/internal/funding/Reconciliation.test.ts +14 -0
- package/src/internal/funding/Reconciliation.ts +161 -86
- package/src/internal/funding/index.test.ts +1 -0
- package/src/internal/funding/index.ts +1 -0
- package/src/internal/webhookDestinations/slack.ts +15 -0
- package/src/Webhooks.bench.ts +0 -714
- package/src/Webhooks.memory.bench.ts +0 -250
- 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
|
|
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:
|
|
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: [
|
|
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: '
|
|
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 =
|
|
1433
|
+
const startBlockNumber =
|
|
1434
|
+
body.eventType === 'funding:deposit.updated'
|
|
1435
|
+
? undefined
|
|
1436
|
+
: await getRpcHead(c.get('getClient')(chainId))
|
|
1354
1437
|
const subscription = await Webhooks.createSubscription(
|
|
1355
1438
|
db,
|
|
1356
1439
|
{
|
|
1357
1440
|
chainId,
|
|
1358
1441
|
...(body.context === undefined ? {} : { context: body.context }),
|
|
1359
1442
|
destination: body.destination,
|
|
1443
|
+
...(principal?.type === 'api_key'
|
|
1444
|
+
? {
|
|
1445
|
+
environment: principal.environment,
|
|
1446
|
+
...(principal.projectId === undefined
|
|
1447
|
+
? {}
|
|
1448
|
+
: { projectId: principal.projectId }),
|
|
1449
|
+
}
|
|
1450
|
+
: {}),
|
|
1360
1451
|
eventType: body.eventType,
|
|
1361
1452
|
filters: body.filters,
|
|
1362
1453
|
owner,
|
|
@@ -1364,7 +1455,7 @@ export function webhooks(options: webhooks.Options = {}) {
|
|
|
1364
1455
|
},
|
|
1365
1456
|
{
|
|
1366
1457
|
maxPerOwner,
|
|
1367
|
-
startBlockNumber,
|
|
1458
|
+
...(startBlockNumber === undefined ? {} : { startBlockNumber }),
|
|
1368
1459
|
},
|
|
1369
1460
|
)
|
|
1370
1461
|
return c.json(
|
|
@@ -1408,6 +1499,7 @@ export function webhooks(options: webhooks.Options = {}) {
|
|
|
1408
1499
|
const db = Db.get(c.get('db'))
|
|
1409
1500
|
const owner = ownerFor(c)
|
|
1410
1501
|
if (!owner) return unauthorized(c)
|
|
1502
|
+
const access = accessFor(c)
|
|
1411
1503
|
|
|
1412
1504
|
const { cursor, include, limit, page } = c.req.valid('query')
|
|
1413
1505
|
try {
|
|
@@ -1415,12 +1507,13 @@ export function webhooks(options: webhooks.Options = {}) {
|
|
|
1415
1507
|
// run it concurrently with the page list. Best-effort: a failure
|
|
1416
1508
|
// omits `meta` rather than failing the page.
|
|
1417
1509
|
const countPromise = include.includes('totalCount')
|
|
1418
|
-
? Webhooks.countSubscriptions(db, owner).catch(() => undefined)
|
|
1510
|
+
? Webhooks.countSubscriptions(db, owner, { access }).catch(() => undefined)
|
|
1419
1511
|
: undefined
|
|
1420
1512
|
|
|
1421
1513
|
// Fetch one extra to detect a further page without a second round-trip.
|
|
1422
1514
|
// `page` translates to a positional slice of `(page - 1) * limit` rows.
|
|
1423
1515
|
const rows = await Webhooks.listSubscriptions(db, owner, {
|
|
1516
|
+
access,
|
|
1424
1517
|
cursor,
|
|
1425
1518
|
limit: limit + 1,
|
|
1426
1519
|
offset: page !== undefined && page > 1 ? (page - 1) * limit : undefined,
|
|
@@ -1488,10 +1581,11 @@ export function webhooks(options: webhooks.Options = {}) {
|
|
|
1488
1581
|
const db = Db.get(c.get('db'))
|
|
1489
1582
|
const owner = ownerFor(c)
|
|
1490
1583
|
if (!owner) return unauthorized(c)
|
|
1584
|
+
const access = accessFor(c)
|
|
1491
1585
|
|
|
1492
1586
|
const { id } = c.req.valid('param')
|
|
1493
1587
|
try {
|
|
1494
|
-
const subscription = await Webhooks.getSubscription(db, owner, id)
|
|
1588
|
+
const subscription = await Webhooks.getSubscription(db, owner, id, { access })
|
|
1495
1589
|
if (!subscription) return notFound(c)
|
|
1496
1590
|
return c.json(
|
|
1497
1591
|
Response.validated(schema.getWebhook.Response, toPublic(subscription)),
|
|
@@ -1549,13 +1643,14 @@ export function webhooks(options: webhooks.Options = {}) {
|
|
|
1549
1643
|
const db = Db.get(c.get('db'))
|
|
1550
1644
|
const owner = ownerFor(c)
|
|
1551
1645
|
if (!owner) return unauthorized(c)
|
|
1646
|
+
const access = accessFor(c)
|
|
1552
1647
|
|
|
1553
1648
|
const { id } = c.req.valid('param')
|
|
1554
1649
|
const { cursor, include, limit, page } = c.req.valid('query')
|
|
1555
1650
|
try {
|
|
1556
1651
|
// Owner-scope first: resolving the subscription under the caller's owner
|
|
1557
1652
|
// key 404s ids they don't own (indistinguishable from "doesn't exist").
|
|
1558
|
-
const subscription = await Webhooks.getSubscription(db, owner, id)
|
|
1653
|
+
const subscription = await Webhooks.getSubscription(db, owner, id, { access })
|
|
1559
1654
|
if (!subscription) return notFound(c)
|
|
1560
1655
|
|
|
1561
1656
|
// `totalCount` is opt-in and exact (the delivery log is TTL-bounded);
|
|
@@ -1632,11 +1727,12 @@ export function webhooks(options: webhooks.Options = {}) {
|
|
|
1632
1727
|
const db = Db.get(c.get('db'))
|
|
1633
1728
|
const owner = ownerFor(c)
|
|
1634
1729
|
if (!owner) return unauthorized(c)
|
|
1730
|
+
const access = accessFor(c)
|
|
1635
1731
|
|
|
1636
1732
|
const { id } = c.req.valid('param')
|
|
1637
1733
|
try {
|
|
1638
1734
|
// Owner-scope first so unowned/unknown ids are indistinguishable (404).
|
|
1639
|
-
const subscription = await Webhooks.getSubscription(db, owner, id)
|
|
1735
|
+
const subscription = await Webhooks.getSubscription(db, owner, id, { access })
|
|
1640
1736
|
if (!subscription) return notFound(c)
|
|
1641
1737
|
|
|
1642
1738
|
// Inline delivery (not the queue) so the caller gets an immediate
|
|
@@ -1696,11 +1792,12 @@ export function webhooks(options: webhooks.Options = {}) {
|
|
|
1696
1792
|
const db = Db.get(c.get('db'))
|
|
1697
1793
|
const owner = ownerFor(c)
|
|
1698
1794
|
if (!owner) return unauthorized(c)
|
|
1795
|
+
const access = accessFor(c)
|
|
1699
1796
|
|
|
1700
1797
|
const { deliveryId, id } = c.req.valid('param')
|
|
1701
1798
|
try {
|
|
1702
1799
|
// Owner-scope the subscription first so unowned ids 404 like elsewhere.
|
|
1703
|
-
const subscription = await Webhooks.getSubscription(db, owner, id)
|
|
1800
|
+
const subscription = await Webhooks.getSubscription(db, owner, id, { access })
|
|
1704
1801
|
if (!subscription) return notFound(c)
|
|
1705
1802
|
|
|
1706
1803
|
const delivery = await Webhooks.getDelivery(db, id, deliveryId)
|
|
@@ -1786,6 +1883,7 @@ export function webhooks(options: webhooks.Options = {}) {
|
|
|
1786
1883
|
const db = Db.get(c.get('db'))
|
|
1787
1884
|
const owner = ownerFor(c)
|
|
1788
1885
|
if (!owner) return unauthorized(c)
|
|
1886
|
+
const access = accessFor(c)
|
|
1789
1887
|
|
|
1790
1888
|
const { id } = c.req.valid('param')
|
|
1791
1889
|
const patch = c.req.valid('json')
|
|
@@ -1796,11 +1894,11 @@ export function webhooks(options: webhooks.Options = {}) {
|
|
|
1796
1894
|
// persisting — otherwise a bad filter would silently widen the match
|
|
1797
1895
|
// set at scan time.
|
|
1798
1896
|
if (patch.filters !== undefined) {
|
|
1799
|
-
const existing = await Webhooks.getSubscription(db, owner, id)
|
|
1897
|
+
const existing = await Webhooks.getSubscription(db, owner, id, { access })
|
|
1800
1898
|
if (!existing) return notFound(c)
|
|
1801
1899
|
parseFilters(existing.eventType, patch.filters)
|
|
1802
1900
|
}
|
|
1803
|
-
const subscription = await Webhooks.updateSubscription(db, owner, id, patch)
|
|
1901
|
+
const subscription = await Webhooks.updateSubscription(db, owner, id, patch, { access })
|
|
1804
1902
|
if (!subscription) return notFound(c)
|
|
1805
1903
|
return c.json(
|
|
1806
1904
|
Response.validated(schema.updateWebhook.Response, toPublic(subscription)),
|
|
@@ -1849,10 +1947,11 @@ export function webhooks(options: webhooks.Options = {}) {
|
|
|
1849
1947
|
const db = Db.get(c.get('db'))
|
|
1850
1948
|
const owner = ownerFor(c)
|
|
1851
1949
|
if (!owner) return unauthorized(c)
|
|
1950
|
+
const access = accessFor(c)
|
|
1852
1951
|
|
|
1853
1952
|
const { id } = c.req.valid('param')
|
|
1854
1953
|
try {
|
|
1855
|
-
const deleted = await Webhooks.deleteSubscription(db, owner, id)
|
|
1954
|
+
const deleted = await Webhooks.deleteSubscription(db, owner, id, { access })
|
|
1856
1955
|
if (!deleted) return notFound(c)
|
|
1857
1956
|
return c.json(Response.validated(schema.deleteWebhook.Response, { id }), 200)
|
|
1858
1957
|
} catch (cause) {
|
|
@@ -1866,626 +1965,13 @@ export function webhooks(options: webhooks.Options = {}) {
|
|
|
1866
1965
|
export declare namespace webhooks {
|
|
1867
1966
|
/** Options for creating webhook routes. */
|
|
1868
1967
|
type Options = {
|
|
1968
|
+
/** Application events produced by this deployment outside the per-block chain scan. */
|
|
1969
|
+
applicationEventTypes?: readonly Webhooks.EventType[] | undefined
|
|
1869
1970
|
/** Whether webhook state is configured and routes should appear in OpenAPI. */
|
|
1870
1971
|
enabled?: boolean | undefined
|
|
1871
1972
|
}
|
|
1872
1973
|
}
|
|
1873
1974
|
|
|
1874
|
-
/**
|
|
1875
|
-
* Largest representable in-block index, used as an end-of-block cursor sentinel
|
|
1876
|
-
* so a fully-scanned block advances the cursor strictly past it (no row index
|
|
1877
|
-
* can equal it).
|
|
1878
|
-
*/
|
|
1879
|
-
const endOfBlockIndex = 2_147_483_647
|
|
1880
|
-
const scanPageSize = 100
|
|
1881
|
-
|
|
1882
|
-
/** A scanned row paired with its `(block, index)` keyset position. */
|
|
1883
|
-
type ScanItem = { cursor: readonly [number, number]; data: unknown }
|
|
1884
|
-
|
|
1885
|
-
/**
|
|
1886
|
-
* The result of scanning one subscription page. `scannedThrough` is the
|
|
1887
|
-
* highest block fully scanned. A batch-limited page omits it, so the poller
|
|
1888
|
-
* resumes after the last dispatched row instead of skipping later matches.
|
|
1889
|
-
*/
|
|
1890
|
-
type ScanPage = {
|
|
1891
|
-
hasMore?: boolean | undefined
|
|
1892
|
-
items: readonly ScanItem[]
|
|
1893
|
-
scannedThrough?: number | undefined
|
|
1894
|
-
}
|
|
1895
|
-
|
|
1896
|
-
/** Per-subscription state retained between fair scan passes. */
|
|
1897
|
-
type PollState = {
|
|
1898
|
-
cursor: string
|
|
1899
|
-
filters: Record<string, unknown>
|
|
1900
|
-
remaining: number
|
|
1901
|
-
safeBlock: number
|
|
1902
|
-
subscription: Webhooks.Subscription
|
|
1903
|
-
}
|
|
1904
|
-
|
|
1905
|
-
/** Concurrent per-subscription scans and cursor writes per poll round; caps RPC and DB fan-out. */
|
|
1906
|
-
const pollConcurrency = 8
|
|
1907
|
-
|
|
1908
|
-
/** Event types scanned together when their combined state fits one existing scan round. */
|
|
1909
|
-
const pollEventTypeConcurrency = 2
|
|
1910
|
-
|
|
1911
|
-
/** Maximum subscription states retained by concurrent event-type scan batches. */
|
|
1912
|
-
const pollStateLimit = WebhookTransfer.scanCohortSize * pollConcurrency
|
|
1913
|
-
|
|
1914
|
-
type Schedule = <value>(fn: () => Promise<value>) => Promise<value>
|
|
1915
|
-
|
|
1916
|
-
/** Schedules asynchronous work through one shared concurrency bound. */
|
|
1917
|
-
function createLimiter(limit: number): Schedule {
|
|
1918
|
-
let active = 0
|
|
1919
|
-
const waiting: (() => void)[] = []
|
|
1920
|
-
return async (fn) => {
|
|
1921
|
-
if (active >= limit) await new Promise<void>((resolve) => waiting.push(resolve))
|
|
1922
|
-
else active++
|
|
1923
|
-
try {
|
|
1924
|
-
return await fn()
|
|
1925
|
-
} finally {
|
|
1926
|
-
const next = waiting.shift()
|
|
1927
|
-
if (next) next()
|
|
1928
|
-
else active--
|
|
1929
|
-
}
|
|
1930
|
-
}
|
|
1931
|
-
}
|
|
1932
|
-
|
|
1933
|
-
/** Runs `fn` over `items` with at most `limit` in flight, preserving order. */
|
|
1934
|
-
async function mapWithLimit<item, value>(
|
|
1935
|
-
items: readonly item[],
|
|
1936
|
-
limit: number,
|
|
1937
|
-
fn: (item: item) => Promise<value>,
|
|
1938
|
-
): Promise<PromiseSettledResult<value>[]> {
|
|
1939
|
-
const results: PromiseSettledResult<value>[] = []
|
|
1940
|
-
let next = 0
|
|
1941
|
-
await Promise.all(
|
|
1942
|
-
Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
1943
|
-
while (next < items.length) {
|
|
1944
|
-
const index = next++
|
|
1945
|
-
try {
|
|
1946
|
-
results[index] = { status: 'fulfilled', value: await fn(items[index]!) }
|
|
1947
|
-
} catch (reason) {
|
|
1948
|
-
results[index] = { reason, status: 'rejected' }
|
|
1949
|
-
}
|
|
1950
|
-
}
|
|
1951
|
-
}),
|
|
1952
|
-
)
|
|
1953
|
-
return results
|
|
1954
|
-
}
|
|
1955
|
-
|
|
1956
|
-
/** Returns `candidate` only when it is lexicographically after `current`. */
|
|
1957
|
-
function laterCursor(current: string, candidate: string): string {
|
|
1958
|
-
const before = Cursor.decode(current, ['int', 'int'])
|
|
1959
|
-
const after = Cursor.decode(candidate, ['int', 'int'])
|
|
1960
|
-
if (!after) return current
|
|
1961
|
-
if (!before) return candidate
|
|
1962
|
-
const [beforeBlock, beforeIndex] = before as [number, number]
|
|
1963
|
-
const [afterBlock, afterIndex] = after as [number, number]
|
|
1964
|
-
return afterBlock > beforeBlock || (afterBlock === beforeBlock && afterIndex > beforeIndex)
|
|
1965
|
-
? candidate
|
|
1966
|
-
: current
|
|
1967
|
-
}
|
|
1968
|
-
|
|
1969
|
-
/**
|
|
1970
|
-
* Polls the chain over RPC for new events and dispatches a signed envelope per
|
|
1971
|
-
* matching row, advancing each active subscription's `(block, index)` keyset
|
|
1972
|
-
* cursor.
|
|
1973
|
-
*
|
|
1974
|
-
* Every event type scans a **narrow confirmed window** `[cursor, head -
|
|
1975
|
-
* confirmations]` directly over JSON-RPC (`eth_getLogs`,
|
|
1976
|
-
* `eth_getBlockByNumber`) so webhook delivery does not wait for an indexer.
|
|
1977
|
-
*
|
|
1978
|
-
* The chain head is injectable (tests pin a fixed historical window); only
|
|
1979
|
-
* blocks at least `confirmations` behind head are emitted, giving at-least-once,
|
|
1980
|
-
* finalized-ish semantics (re-delivery overlap is harmless because the receiver
|
|
1981
|
-
* dedupes on the stable `event.id`).
|
|
1982
|
-
*
|
|
1983
|
-
* Cursor advances only past successfully dispatched rows, so a dispatch failure
|
|
1984
|
-
* pauses that subscription at the last good position and the next tick resumes.
|
|
1985
|
-
* Scan failures leave that subscription unchanged; the first error is rethrown
|
|
1986
|
-
* after the remaining subscriptions run.
|
|
1987
|
-
*/
|
|
1988
|
-
export async function poll(options: poll.Options): Promise<poll.Result> {
|
|
1989
|
-
const {
|
|
1990
|
-
batchSize = 200,
|
|
1991
|
-
cache: cache_metadata = Store.memory(),
|
|
1992
|
-
chainId,
|
|
1993
|
-
client,
|
|
1994
|
-
confirmations = 1,
|
|
1995
|
-
db,
|
|
1996
|
-
eventTypes: eventTypes_input = [
|
|
1997
|
-
'token:transfer',
|
|
1998
|
-
'transaction:included',
|
|
1999
|
-
'log:emitted',
|
|
2000
|
-
'block:created',
|
|
2001
|
-
],
|
|
2002
|
-
maxBlocks = 1_000,
|
|
2003
|
-
} = options
|
|
2004
|
-
const now = options.now ?? (() => new Date())
|
|
2005
|
-
options.signal?.throwIfAborted()
|
|
2006
|
-
// Default (self-host) transport: deliver inline and record the outcome so a
|
|
2007
|
-
// sustained-failure subscription auto-disables. On Cloudflare, callers pass a
|
|
2008
|
-
// Queue producer instead and the consumer runs `deliverAndRecord`.
|
|
2009
|
-
const dispatch =
|
|
2010
|
-
options.dispatch ??
|
|
2011
|
-
(({ envelope, subscription }) => Webhooks.deliverAndRecord(db, subscription, envelope))
|
|
2012
|
-
const dispatchBatch = options.dispatchBatch
|
|
2013
|
-
|
|
2014
|
-
// A pinned `headBlockNumber` lets a head subscription supply the exact live
|
|
2015
|
-
// boundary and keeps historical tests deterministic.
|
|
2016
|
-
const override = options.headBlockNumber
|
|
2017
|
-
let rpcHead: number | undefined = override === undefined ? undefined : Number(override)
|
|
2018
|
-
const resolveRpcHead = async () => (rpcHead ??= await getRpcHead(client))
|
|
2019
|
-
|
|
2020
|
-
const headStartedAt = Date.now()
|
|
2021
|
-
const rpcSafeBlock = await (async () => {
|
|
2022
|
-
try {
|
|
2023
|
-
const safeBlock = Math.max(0, (await resolveRpcHead()) - confirmations)
|
|
2024
|
-
options.onStage?.({
|
|
2025
|
-
durationMs: Date.now() - headStartedAt,
|
|
2026
|
-
outcome: 'ok',
|
|
2027
|
-
stage: 'rpc_head',
|
|
2028
|
-
})
|
|
2029
|
-
return safeBlock
|
|
2030
|
-
} catch (error) {
|
|
2031
|
-
options.onStage?.({
|
|
2032
|
-
durationMs: Date.now() - headStartedAt,
|
|
2033
|
-
outcome: 'error',
|
|
2034
|
-
stage: 'rpc_head',
|
|
2035
|
-
})
|
|
2036
|
-
throw error
|
|
2037
|
-
}
|
|
2038
|
-
})()
|
|
2039
|
-
const result: poll.Result = {
|
|
2040
|
-
chainId,
|
|
2041
|
-
dispatched: 0,
|
|
2042
|
-
failed: 0,
|
|
2043
|
-
safeBlock: rpcSafeBlock,
|
|
2044
|
-
scanned: 0,
|
|
2045
|
-
subscriptions: 0,
|
|
2046
|
-
}
|
|
2047
|
-
let cursorLagBlocks = 0
|
|
2048
|
-
const pollErrors: unknown[] = []
|
|
2049
|
-
|
|
2050
|
-
const eventTypes = [...new Set(eventTypes_input)]
|
|
2051
|
-
const subscriptionsStartedAt = Date.now()
|
|
2052
|
-
const pairs = await Webhooks.listActiveForChain(db, { chainId, eventTypes })
|
|
2053
|
-
options.signal?.throwIfAborted()
|
|
2054
|
-
options.onSubscriptions?.(pairs.length)
|
|
2055
|
-
options.onStage?.({
|
|
2056
|
-
durationMs: Date.now() - subscriptionsStartedAt,
|
|
2057
|
-
outcome: 'ok',
|
|
2058
|
-
stage: 'subscription_read',
|
|
2059
|
-
})
|
|
2060
|
-
const pairsByEventType = Map.groupBy(pairs, (pair) => pair.subscription.eventType)
|
|
2061
|
-
const eventTypes_active = eventTypes.filter(
|
|
2062
|
-
(eventType) => (pairsByEventType.get(eventType)?.length ?? 0) > 0,
|
|
2063
|
-
)
|
|
2064
|
-
const eventTypeConcurrency =
|
|
2065
|
-
pairs.length <= pollStateLimit
|
|
2066
|
-
? Math.min(pollEventTypeConcurrency, eventTypes_active.length)
|
|
2067
|
-
: Math.min(1, eventTypes_active.length)
|
|
2068
|
-
const scanRoundSize = Math.floor(pollStateLimit / Math.max(1, eventTypeConcurrency))
|
|
2069
|
-
const scheduleScan = createLimiter(pollConcurrency)
|
|
2070
|
-
|
|
2071
|
-
const eventTypeResults = await mapWithLimit(
|
|
2072
|
-
eventTypes_active,
|
|
2073
|
-
eventTypeConcurrency,
|
|
2074
|
-
async (eventType) => {
|
|
2075
|
-
options.signal?.throwIfAborted()
|
|
2076
|
-
const safeBlock = rpcSafeBlock
|
|
2077
|
-
const pairs = pairsByEventType.get(eventType) ?? []
|
|
2078
|
-
const durations = {
|
|
2079
|
-
cursor_write: 0,
|
|
2080
|
-
enrich: 0,
|
|
2081
|
-
scan: 0,
|
|
2082
|
-
} satisfies Record<Exclude<MetricSink.PollStage, 'rpc_head' | 'subscription_read'>, number>
|
|
2083
|
-
const observed = new Set<keyof typeof durations>()
|
|
2084
|
-
let cursorWriteOutcome: 'error' | 'ok' = 'ok'
|
|
2085
|
-
let scanOutcome: 'error' | 'ok' = 'ok'
|
|
2086
|
-
const writeCursor = async (parameters: Webhooks.compareAndSetCursor.Options) => {
|
|
2087
|
-
const startedAt = Date.now()
|
|
2088
|
-
observed.add('cursor_write')
|
|
2089
|
-
try {
|
|
2090
|
-
return await Webhooks.compareAndSetCursor(db, parameters)
|
|
2091
|
-
} catch (error) {
|
|
2092
|
-
cursorWriteOutcome = 'error'
|
|
2093
|
-
pollErrors.push(error)
|
|
2094
|
-
return false
|
|
2095
|
-
} finally {
|
|
2096
|
-
durations.cursor_write += Date.now() - startedAt
|
|
2097
|
-
}
|
|
2098
|
-
}
|
|
2099
|
-
let states: PollState[] = []
|
|
2100
|
-
for (const pair of pairs) {
|
|
2101
|
-
const { subscription } = pair
|
|
2102
|
-
result.subscriptions++
|
|
2103
|
-
let filters: Record<string, unknown>
|
|
2104
|
-
try {
|
|
2105
|
-
filters = parseFilters(subscription.eventType, subscription.filters)
|
|
2106
|
-
} catch (error) {
|
|
2107
|
-
if (!(error instanceof Webhooks.InvalidFilterError)) throw error
|
|
2108
|
-
options.signal?.throwIfAborted()
|
|
2109
|
-
// A breaking filter-schema removal must not poison every chain poll.
|
|
2110
|
-
// Disable the legacy subscription without widening its match set.
|
|
2111
|
-
await Webhooks.updateSubscription(db, subscription.owner, subscription.id, {
|
|
2112
|
-
status: 'disabled',
|
|
2113
|
-
})
|
|
2114
|
-
continue
|
|
2115
|
-
}
|
|
2116
|
-
let cursor = pair.cursor
|
|
2117
|
-
if (cursor === null) {
|
|
2118
|
-
// Legacy subscriptions without a creation checkpoint anchor at the
|
|
2119
|
-
// current confirmed head, avoiding a genesis backfill.
|
|
2120
|
-
await writeCursor({
|
|
2121
|
-
cursor: Cursor.encode([safeBlock, endOfBlockIndex]),
|
|
2122
|
-
expectedCursor: null,
|
|
2123
|
-
subscriptionId: subscription.id,
|
|
2124
|
-
})
|
|
2125
|
-
continue
|
|
2126
|
-
}
|
|
2127
|
-
const checkpoint = Cursor.decode(cursor, ['int'])
|
|
2128
|
-
if (checkpoint) {
|
|
2129
|
-
// Creation checkpoints record the live head without finalizing it. Rebase
|
|
2130
|
-
// to this poller's confirmed boundary so replacement blocks stay eligible.
|
|
2131
|
-
const confirmedCursor = Cursor.encode([
|
|
2132
|
-
Math.max(0, (checkpoint[0] as number) - confirmations),
|
|
2133
|
-
endOfBlockIndex,
|
|
2134
|
-
])
|
|
2135
|
-
const committed = await writeCursor({
|
|
2136
|
-
cursor: confirmedCursor,
|
|
2137
|
-
expectedCursor: cursor,
|
|
2138
|
-
subscriptionId: subscription.id,
|
|
2139
|
-
})
|
|
2140
|
-
if (!committed) continue
|
|
2141
|
-
cursor = confirmedCursor
|
|
2142
|
-
}
|
|
2143
|
-
const position = Cursor.decode(cursor, ['int', 'int'])
|
|
2144
|
-
if (position)
|
|
2145
|
-
cursorLagBlocks = Math.max(cursorLagBlocks, safeBlock - (position[0] as number))
|
|
2146
|
-
const window = scanWindow(cursor, safeBlock, maxBlocks)
|
|
2147
|
-
if (window && window.to < safeBlock) result.hasMore = true
|
|
2148
|
-
states.push({
|
|
2149
|
-
cursor,
|
|
2150
|
-
filters,
|
|
2151
|
-
remaining: batchSize,
|
|
2152
|
-
safeBlock: window?.to ?? safeBlock,
|
|
2153
|
-
subscription,
|
|
2154
|
-
})
|
|
2155
|
-
}
|
|
2156
|
-
|
|
2157
|
-
// Every subscription gets one bounded page before any subscription gets a
|
|
2158
|
-
// continuation page, preserving fairness while allowing catch-up.
|
|
2159
|
-
while (states.length > 0) {
|
|
2160
|
-
options.signal?.throwIfAborted()
|
|
2161
|
-
const continuations: PollState[] = []
|
|
2162
|
-
const round = states
|
|
2163
|
-
for (let offset = 0; offset < round.length; offset += scanRoundSize) {
|
|
2164
|
-
const batch = round.slice(offset, offset + scanRoundSize)
|
|
2165
|
-
const windows = batch.flatMap((state) => {
|
|
2166
|
-
const window = scanWindow(state.cursor, state.safeBlock, maxBlocks)
|
|
2167
|
-
return window ? [window] : []
|
|
2168
|
-
})
|
|
2169
|
-
const maxWindowBlocks = Math.max(
|
|
2170
|
-
0,
|
|
2171
|
-
...windows.map((window) => window.to - window.from + 1),
|
|
2172
|
-
)
|
|
2173
|
-
const totalWindowBlocks = windows.reduce(
|
|
2174
|
-
(total, window) => total + window.to - window.from + 1,
|
|
2175
|
-
0,
|
|
2176
|
-
)
|
|
2177
|
-
options.onScanCheckpoint?.({
|
|
2178
|
-
concurrency: pollConcurrency,
|
|
2179
|
-
eventType,
|
|
2180
|
-
maxWindowBlocks,
|
|
2181
|
-
phase: 'start',
|
|
2182
|
-
rpcMethod: scanRpcMethod(eventType),
|
|
2183
|
-
subscriptionLimit: scanRoundSize,
|
|
2184
|
-
subscriptionCount: batch.length,
|
|
2185
|
-
totalWindowBlocks,
|
|
2186
|
-
})
|
|
2187
|
-
const scanStartedAt = Date.now()
|
|
2188
|
-
const pages = await scanSubscriptions({
|
|
2189
|
-
client,
|
|
2190
|
-
eventType,
|
|
2191
|
-
maxBlocks,
|
|
2192
|
-
schedule: scheduleScan,
|
|
2193
|
-
states: batch,
|
|
2194
|
-
})
|
|
2195
|
-
options.onScanCheckpoint?.({
|
|
2196
|
-
concurrency: pollConcurrency,
|
|
2197
|
-
durationMs: Date.now() - scanStartedAt,
|
|
2198
|
-
eventType,
|
|
2199
|
-
failedSubscriptions: pages.filter((page) => page.status === 'rejected').length,
|
|
2200
|
-
matchedItems: pages.reduce(
|
|
2201
|
-
(total, page) => total + (page.status === 'fulfilled' ? page.value.items.length : 0),
|
|
2202
|
-
0,
|
|
2203
|
-
),
|
|
2204
|
-
maxWindowBlocks,
|
|
2205
|
-
outcome: scanResultOutcome(pages),
|
|
2206
|
-
phase: 'complete',
|
|
2207
|
-
rpcMethod: scanRpcMethod(eventType),
|
|
2208
|
-
subscriptionLimit: scanRoundSize,
|
|
2209
|
-
subscriptionCount: batch.length,
|
|
2210
|
-
totalWindowBlocks,
|
|
2211
|
-
})
|
|
2212
|
-
options.signal?.throwIfAborted()
|
|
2213
|
-
durations.scan += Date.now() - scanStartedAt
|
|
2214
|
-
observed.add('scan')
|
|
2215
|
-
if (pages.some((page) => page.status === 'rejected')) scanOutcome = 'error'
|
|
2216
|
-
|
|
2217
|
-
type Scanned = { page: ScanPage; state: PollState }
|
|
2218
|
-
const busy: Scanned[] = []
|
|
2219
|
-
const idle: Scanned[] = []
|
|
2220
|
-
for (let i = 0; i < batch.length; i++) {
|
|
2221
|
-
const state = batch[i]!
|
|
2222
|
-
const settled = pages[i]!
|
|
2223
|
-
if (settled.status === 'rejected') {
|
|
2224
|
-
pollErrors.push(settled.reason)
|
|
2225
|
-
continue
|
|
2226
|
-
}
|
|
2227
|
-
result.scanned += settled.value.items.length
|
|
2228
|
-
state.remaining -= settled.value.items.length
|
|
2229
|
-
;(settled.value.items.length > 0 ? busy : idle).push({ page: settled.value, state })
|
|
2230
|
-
}
|
|
2231
|
-
|
|
2232
|
-
// Empty pages only advance their cursor: no dispatch order to preserve,
|
|
2233
|
-
// so one statement commits the cohort while retaining per-row CAS semantics.
|
|
2234
|
-
const cursorWritesStartedAt = Date.now()
|
|
2235
|
-
const candidates = idle.flatMap(({ page, state }) => {
|
|
2236
|
-
options.signal?.throwIfAborted()
|
|
2237
|
-
const candidate =
|
|
2238
|
-
page.scannedThrough !== undefined
|
|
2239
|
-
? Cursor.encode([page.scannedThrough, endOfBlockIndex])
|
|
2240
|
-
: page.hasMore
|
|
2241
|
-
? undefined
|
|
2242
|
-
: Cursor.encode([state.safeBlock, endOfBlockIndex])
|
|
2243
|
-
const cursor = candidate ? laterCursor(state.cursor, candidate) : state.cursor
|
|
2244
|
-
return cursor === state.cursor ? [] : [{ cursor, state }]
|
|
2245
|
-
})
|
|
2246
|
-
if (candidates.length > 0) {
|
|
2247
|
-
const committed = await (async () => {
|
|
2248
|
-
try {
|
|
2249
|
-
return await Webhooks.compareAndSetCursors(db, {
|
|
2250
|
-
cursors: candidates.map(({ cursor, state }) => ({
|
|
2251
|
-
cursor,
|
|
2252
|
-
expectedCursor: state.cursor,
|
|
2253
|
-
subscriptionId: state.subscription.id,
|
|
2254
|
-
})),
|
|
2255
|
-
})
|
|
2256
|
-
} catch (error) {
|
|
2257
|
-
cursorWriteOutcome = 'error'
|
|
2258
|
-
pollErrors.push(error)
|
|
2259
|
-
return new Set<string>()
|
|
2260
|
-
}
|
|
2261
|
-
})()
|
|
2262
|
-
for (const { cursor, state } of candidates)
|
|
2263
|
-
if (committed.has(state.subscription.id)) state.cursor = cursor
|
|
2264
|
-
durations.cursor_write += Date.now() - cursorWritesStartedAt
|
|
2265
|
-
observed.add('cursor_write')
|
|
2266
|
-
}
|
|
2267
|
-
|
|
2268
|
-
for (const { page, state } of busy) {
|
|
2269
|
-
options.signal?.throwIfAborted()
|
|
2270
|
-
const { subscription } = state
|
|
2271
|
-
|
|
2272
|
-
let accepted: readonly [number, number] | undefined
|
|
2273
|
-
let failed = false
|
|
2274
|
-
const buildDispatchable = async (item: ScanItem): Promise<poll.Dispatchable> => {
|
|
2275
|
-
options.signal?.throwIfAborted()
|
|
2276
|
-
// Token metadata is best-effort for webhooks: transfers use it for display,
|
|
2277
|
-
// while transactions expose fee-token references without blocking delivery.
|
|
2278
|
-
const enrichStartedAt = Date.now()
|
|
2279
|
-
const data = await enrichEventData(eventType, item.data, {
|
|
2280
|
-
cache: cache_metadata,
|
|
2281
|
-
chainId,
|
|
2282
|
-
client,
|
|
2283
|
-
})
|
|
2284
|
-
durations.enrich += Date.now() - enrichStartedAt
|
|
2285
|
-
observed.add('enrich')
|
|
2286
|
-
options.signal?.throwIfAborted()
|
|
2287
|
-
const envelope = Webhooks.buildEnvelope({
|
|
2288
|
-
blockNumber: item.cursor[0],
|
|
2289
|
-
createdAt: now(),
|
|
2290
|
-
data,
|
|
2291
|
-
logIndex: item.cursor[1],
|
|
2292
|
-
subscription,
|
|
2293
|
-
})
|
|
2294
|
-
return { envelope, subscription }
|
|
2295
|
-
}
|
|
2296
|
-
|
|
2297
|
-
if (dispatchBatch) {
|
|
2298
|
-
const dispatchables: poll.Dispatchable[] = []
|
|
2299
|
-
for (const item of page.items) dispatchables.push(await buildDispatchable(item))
|
|
2300
|
-
options.signal?.throwIfAborted()
|
|
2301
|
-
const delivered = await dispatchBatch(dispatchables, { db })
|
|
2302
|
-
if (delivered.ok) {
|
|
2303
|
-
accepted = page.items.at(-1)?.cursor
|
|
2304
|
-
result.dispatched += dispatchables.length
|
|
2305
|
-
} else {
|
|
2306
|
-
result.failed += dispatchables.length
|
|
2307
|
-
failed = true
|
|
2308
|
-
}
|
|
2309
|
-
} else {
|
|
2310
|
-
for (const item of page.items) {
|
|
2311
|
-
const dispatchable = await buildDispatchable(item)
|
|
2312
|
-
const delivered = await dispatch(dispatchable)
|
|
2313
|
-
if (!delivered.ok) {
|
|
2314
|
-
result.failed++
|
|
2315
|
-
failed = true
|
|
2316
|
-
break
|
|
2317
|
-
}
|
|
2318
|
-
accepted = item.cursor
|
|
2319
|
-
result.dispatched++
|
|
2320
|
-
}
|
|
2321
|
-
}
|
|
2322
|
-
|
|
2323
|
-
// Persist every accepted page before another subscription begins its
|
|
2324
|
-
// continuation, so a later scan failure retains completed work.
|
|
2325
|
-
const candidate = (() => {
|
|
2326
|
-
if (failed) return accepted ? Cursor.encode([...accepted]) : undefined
|
|
2327
|
-
if (page.scannedThrough !== undefined)
|
|
2328
|
-
return Cursor.encode([page.scannedThrough, endOfBlockIndex])
|
|
2329
|
-
if (accepted) return Cursor.encode([...accepted])
|
|
2330
|
-
if (!page.hasMore) return Cursor.encode([state.safeBlock, endOfBlockIndex])
|
|
2331
|
-
return undefined
|
|
2332
|
-
})()
|
|
2333
|
-
const cursor = candidate ? laterCursor(state.cursor, candidate) : state.cursor
|
|
2334
|
-
const progressed = cursor !== state.cursor
|
|
2335
|
-
let committed = false
|
|
2336
|
-
if (progressed) {
|
|
2337
|
-
committed = await writeCursor({
|
|
2338
|
-
cursor,
|
|
2339
|
-
expectedCursor: state.cursor,
|
|
2340
|
-
subscriptionId: subscription.id,
|
|
2341
|
-
})
|
|
2342
|
-
if (committed) state.cursor = cursor
|
|
2343
|
-
}
|
|
2344
|
-
|
|
2345
|
-
if (!failed && page.hasMore === true) {
|
|
2346
|
-
if (committed && state.remaining > 0) continuations.push(state)
|
|
2347
|
-
else result.hasMore = true
|
|
2348
|
-
}
|
|
2349
|
-
}
|
|
2350
|
-
}
|
|
2351
|
-
states = continuations
|
|
2352
|
-
}
|
|
2353
|
-
for (const stage of observed) {
|
|
2354
|
-
const outcome = (() => {
|
|
2355
|
-
if (stage === 'cursor_write') return cursorWriteOutcome
|
|
2356
|
-
if (stage === 'scan') return scanOutcome
|
|
2357
|
-
return 'ok'
|
|
2358
|
-
})()
|
|
2359
|
-
options.onStage?.({
|
|
2360
|
-
durationMs: durations[stage],
|
|
2361
|
-
eventType,
|
|
2362
|
-
outcome,
|
|
2363
|
-
stage,
|
|
2364
|
-
})
|
|
2365
|
-
}
|
|
2366
|
-
},
|
|
2367
|
-
)
|
|
2368
|
-
for (const result of eventTypeResults)
|
|
2369
|
-
if (result.status === 'rejected') pollErrors.push(result.reason)
|
|
2370
|
-
|
|
2371
|
-
options.onCursorLag?.(cursorLagBlocks)
|
|
2372
|
-
if (pollErrors.length > 0) throw pollErrors[0]
|
|
2373
|
-
return result
|
|
2374
|
-
}
|
|
2375
|
-
|
|
2376
|
-
export declare namespace poll {
|
|
2377
|
-
/** A row to dispatch, paired with its owning subscription. */
|
|
2378
|
-
type Dispatchable = {
|
|
2379
|
-
/** The signed-on-dispatch delivery envelope. */
|
|
2380
|
-
envelope: Webhooks.Envelope
|
|
2381
|
-
/** The subscription the event matched. */
|
|
2382
|
-
subscription: Webhooks.Subscription
|
|
2383
|
-
}
|
|
2384
|
-
/** Options for {@link poll}. */
|
|
2385
|
-
type Options = {
|
|
2386
|
-
/** Max matching rows scanned per subscription per tick (default 200). */
|
|
2387
|
-
batchSize?: number | undefined
|
|
2388
|
-
/** Cache store for the token-metadata memoize (defaults to a fresh in-memory store; pass a persistent one to share lookups across ticks). */
|
|
2389
|
-
cache?: Store.Store | undefined
|
|
2390
|
-
/** Chain to poll. Callers loop over chains. */
|
|
2391
|
-
chainId: number
|
|
2392
|
-
/** viem RPC client for the chain. */
|
|
2393
|
-
client: Viem.getClient.ReturnType
|
|
2394
|
-
/** Reorg-safety window: only emit blocks this far behind head (default 1). */
|
|
2395
|
-
confirmations?: number | undefined
|
|
2396
|
-
/** Database holding subscriptions + cursors. */
|
|
2397
|
-
db: Db.Db
|
|
2398
|
-
/**
|
|
2399
|
-
* Delivery transport. Defaults to inline {@link Webhooks.deliverAndRecord} (self-host);
|
|
2400
|
-
* on Cloudflare pass a Queue producer so retry/backoff/DLQ are native.
|
|
2401
|
-
*/
|
|
2402
|
-
dispatch?: ((input: Dispatchable) => Promise<Webhooks.Result>) | undefined
|
|
2403
|
-
/** Batch delivery transport. A successful result confirms every row in the input. */
|
|
2404
|
-
dispatchBatch?:
|
|
2405
|
-
| ((input: readonly Dispatchable[], context: DispatchContext) => Promise<Webhooks.Result>)
|
|
2406
|
-
| undefined
|
|
2407
|
-
/** Event types to poll (default all). */
|
|
2408
|
-
eventTypes?: readonly EventType[] | undefined
|
|
2409
|
-
/** Current chain head; the poller emits up to `head - confirmations`. */
|
|
2410
|
-
headBlockNumber?: bigint | number | undefined
|
|
2411
|
-
/** Backfill cap: most blocks an RPC scanner inspects per tick (default 1000). */
|
|
2412
|
-
maxBlocks?: number | undefined
|
|
2413
|
-
/** Records the worst confirmed-head distance without changing the poll result. */
|
|
2414
|
-
onCursorLag?: ((blocks: number) => void) | undefined
|
|
2415
|
-
/** Records bounded scan inputs before allocation and outcomes after completion. */
|
|
2416
|
-
onScanCheckpoint?: ((checkpoint: ScanCheckpoint) => void) | undefined
|
|
2417
|
-
/** Records bounded, per-tick poll-stage timings without subscription identities. */
|
|
2418
|
-
onStage?: ((stage: Stage) => void) | undefined
|
|
2419
|
-
/** Records subscriptions eligible for this poll before any scan runs. */
|
|
2420
|
-
onSubscriptions?: ((count: number) => void) | undefined
|
|
2421
|
-
/** Clock for envelope timestamps; injectable for tests. */
|
|
2422
|
-
now?: (() => Date) | undefined
|
|
2423
|
-
/** Stops scanning and dispatch when the caller loses its execution lease. */
|
|
2424
|
-
signal?: AbortSignal | undefined
|
|
2425
|
-
}
|
|
2426
|
-
|
|
2427
|
-
/** Runtime context shared with a batch delivery transport. */
|
|
2428
|
-
type DispatchContext = {
|
|
2429
|
-
/** Database instance already resolved for this poll. */
|
|
2430
|
-
db: Db.Db
|
|
2431
|
-
}
|
|
2432
|
-
/** Summary counters for one poll tick. */
|
|
2433
|
-
type Result = {
|
|
2434
|
-
/** Chain that was polled. */
|
|
2435
|
-
chainId: number
|
|
2436
|
-
/** Rows successfully dispatched. */
|
|
2437
|
-
dispatched: number
|
|
2438
|
-
/** Rows whose dispatch failed (pausing that subscription). */
|
|
2439
|
-
failed: number
|
|
2440
|
-
/** True when a bounded scan left committed work behind the same head. */
|
|
2441
|
-
hasMore?: boolean | undefined
|
|
2442
|
-
/** Highest confirmed block emitted this tick (`head - confirmations`). */
|
|
2443
|
-
safeBlock: number
|
|
2444
|
-
/** Total rows scanned across all subscriptions. */
|
|
2445
|
-
scanned: number
|
|
2446
|
-
/** Active subscriptions visited. */
|
|
2447
|
-
subscriptions: number
|
|
2448
|
-
}
|
|
2449
|
-
/** One scan boundary used to correlate production memory failures with workload shape. */
|
|
2450
|
-
type ScanCheckpoint = {
|
|
2451
|
-
/** Shared upper bound for concurrent upstream scans across event types. */
|
|
2452
|
-
concurrency: number
|
|
2453
|
-
/** Scan duration, present only after completion. */
|
|
2454
|
-
durationMs?: number | undefined
|
|
2455
|
-
/** Subscription event type scanned by this batch. */
|
|
2456
|
-
eventType: EventType
|
|
2457
|
-
/** Subscriptions whose scan rejected, present only after completion. */
|
|
2458
|
-
failedSubscriptions?: number | undefined
|
|
2459
|
-
/** Matching rows retained by completed pages, present only after completion. */
|
|
2460
|
-
matchedItems?: number | undefined
|
|
2461
|
-
/** Largest block window requested by one subscription. */
|
|
2462
|
-
maxWindowBlocks: number
|
|
2463
|
-
/** Whether the checkpoint precedes allocation or follows scan completion. */
|
|
2464
|
-
phase: 'complete' | 'start'
|
|
2465
|
-
/** Outcome of the completed RPC-backed scan. */
|
|
2466
|
-
outcome?: 'empty' | 'error' | 'missing' | 'ok' | undefined
|
|
2467
|
-
/** JSON-RPC method used by this event type's scanner. */
|
|
2468
|
-
rpcMethod: 'eth_getBlockByNumber' | 'eth_getLogs'
|
|
2469
|
-
/** Subscriptions retained in this scan batch. */
|
|
2470
|
-
subscriptionCount: number
|
|
2471
|
-
/** Maximum subscriptions retained by this event-type batch. */
|
|
2472
|
-
subscriptionLimit: number
|
|
2473
|
-
/** Sum of subscription block windows in this scan batch. */
|
|
2474
|
-
totalWindowBlocks: number
|
|
2475
|
-
}
|
|
2476
|
-
/** One bounded poll-stage timing observation. */
|
|
2477
|
-
type Stage = {
|
|
2478
|
-
/** Stage duration in milliseconds. */
|
|
2479
|
-
durationMs: number
|
|
2480
|
-
/** Event type being processed, omitted for the shared RPC-head stage. */
|
|
2481
|
-
eventType?: EventType | undefined
|
|
2482
|
-
/** Whether the stage completed. */
|
|
2483
|
-
outcome: 'error' | 'ok'
|
|
2484
|
-
/** Bounded stage name. */
|
|
2485
|
-
stage: MetricSink.PollStage
|
|
2486
|
-
}
|
|
2487
|
-
}
|
|
2488
|
-
|
|
2489
1975
|
/**
|
|
2490
1976
|
* Attaches best-effort `{ symbol, decimals }` token metadata to a
|
|
2491
1977
|
* `token:transfer` row so destinations can render human amounts. Reads the row's
|
|
@@ -2596,78 +2082,9 @@ export async function getRpcHead(client: Viem.getClient.ReturnType): Promise<num
|
|
|
2596
2082
|
return Number(await client.getBlockNumber())
|
|
2597
2083
|
}
|
|
2598
2084
|
|
|
2599
|
-
/** Groups aligned cursors only when their bounded upstream scans are equivalent. */
|
|
2600
|
-
async function scanSubscriptions(options: {
|
|
2601
|
-
client: Viem.getClient.ReturnType
|
|
2602
|
-
eventType: EventType
|
|
2603
|
-
maxBlocks: number
|
|
2604
|
-
schedule: Schedule
|
|
2605
|
-
states: readonly PollState[]
|
|
2606
|
-
}): Promise<PromiseSettledResult<ScanPage>[]> {
|
|
2607
|
-
const { client, eventType, maxBlocks, schedule, states } = options
|
|
2608
|
-
if (eventType !== 'token:transfer')
|
|
2609
|
-
return scanIndependent({ client, eventType, maxBlocks, schedule, states })
|
|
2610
|
-
return WebhookTransfer.scan({
|
|
2611
|
-
client,
|
|
2612
|
-
concurrency: pollConcurrency,
|
|
2613
|
-
maxBlocks,
|
|
2614
|
-
schedule,
|
|
2615
|
-
states: states.map((state) => ({
|
|
2616
|
-
filters: state.filters,
|
|
2617
|
-
limit: Math.min(scanPageSize, state.remaining),
|
|
2618
|
-
window: scanWindow(state.cursor, state.safeBlock, maxBlocks),
|
|
2619
|
-
})),
|
|
2620
|
-
})
|
|
2621
|
-
}
|
|
2622
|
-
|
|
2623
|
-
/** Returns the bounded JSON-RPC method used by an event scanner. */
|
|
2624
|
-
function scanRpcMethod(eventType: EventType): poll.ScanCheckpoint['rpcMethod'] {
|
|
2625
|
-
return eventType === 'block:created' || eventType === 'transaction:included'
|
|
2626
|
-
? 'eth_getBlockByNumber'
|
|
2627
|
-
: 'eth_getLogs'
|
|
2628
|
-
}
|
|
2629
|
-
|
|
2630
|
-
/** Classifies a completed batch without exposing an unbounded upstream error. */
|
|
2631
|
-
function scanResultOutcome(
|
|
2632
|
-
pages: readonly PromiseSettledResult<ScanPage>[],
|
|
2633
|
-
): NonNullable<poll.ScanCheckpoint['outcome']> {
|
|
2634
|
-
const rejected = pages.filter((page) => page.status === 'rejected')
|
|
2635
|
-
if (rejected.some((page) => page.reason instanceof BlockUnavailableError)) return 'missing'
|
|
2636
|
-
if (rejected.length > 0) return 'error'
|
|
2637
|
-
return pages.every((page) => page.status === 'fulfilled' && page.value.items.length === 0)
|
|
2638
|
-
? 'empty'
|
|
2639
|
-
: 'ok'
|
|
2640
|
-
}
|
|
2641
|
-
|
|
2642
|
-
/** Scans CPU-matched streams per subscription while sharing concurrent block reads. */
|
|
2643
|
-
function scanIndependent(options: {
|
|
2644
|
-
client: Viem.getClient.ReturnType
|
|
2645
|
-
eventType: Exclude<EventType, 'token:transfer'>
|
|
2646
|
-
maxBlocks: number
|
|
2647
|
-
schedule: Schedule
|
|
2648
|
-
states: readonly PollState[]
|
|
2649
|
-
}): Promise<PromiseSettledResult<ScanPage>[]> {
|
|
2650
|
-
const { client, eventType, maxBlocks, schedule, states } = options
|
|
2651
|
-
const cache = createChainCache(client)
|
|
2652
|
-
return mapWithLimit(states, pollConcurrency, (state) =>
|
|
2653
|
-
schedule(() => {
|
|
2654
|
-
const shared = {
|
|
2655
|
-
batchSize: Math.min(scanPageSize, state.remaining),
|
|
2656
|
-
cache,
|
|
2657
|
-
cursor: state.cursor,
|
|
2658
|
-
filters: state.filters,
|
|
2659
|
-
maxBlocks,
|
|
2660
|
-
safeBlock: state.safeBlock,
|
|
2661
|
-
}
|
|
2662
|
-
if (eventType === 'block:created') return scanBlocks(shared)
|
|
2663
|
-
if (eventType === 'log:emitted') return scanLogs({ ...shared, client })
|
|
2664
|
-
return scanTxs(shared)
|
|
2665
|
-
}),
|
|
2666
|
-
)
|
|
2667
|
-
}
|
|
2668
|
-
|
|
2669
2085
|
const filterSchemas = {
|
|
2670
2086
|
'block:created': schema.BlockFilters,
|
|
2087
|
+
'funding:deposit.updated': schema.FundingDepositFilters,
|
|
2671
2088
|
'log:emitted': schema.LogFilters,
|
|
2672
2089
|
'token:transfer': schema.TransferFilters,
|
|
2673
2090
|
'transaction:included': schema.TxFilters,
|
|
@@ -2676,7 +2093,7 @@ const filterSchemas = {
|
|
|
2676
2093
|
/**
|
|
2677
2094
|
* Validates `filters` against the schema for `eventType`, **failing closed**: an
|
|
2678
2095
|
* invalid filter throws {@link Webhooks.InvalidFilterError} instead of degrading
|
|
2679
|
-
* to `{}` (a firehose). Used at create/patch time and by
|
|
2096
|
+
* to `{}` (a firehose). Used at create/patch time and by {@link scanBlock} so a stored
|
|
2680
2097
|
* bad filter can never silently widen the match set.
|
|
2681
2098
|
*/
|
|
2682
2099
|
export function parseFilters(
|
|
@@ -2732,7 +2149,7 @@ function operandEquals(value: string, target: unknown, kind: 'number' | 'string'
|
|
|
2732
2149
|
* The JS counterpart of the old SQL `applyOperator`: evaluates one parsed filter
|
|
2733
2150
|
* operator (from {@link schema.eq}/{@link schema.compare}/{@link schema.bytes})
|
|
2734
2151
|
* against a single `0x`-hex field value pulled from an RPC object. The one place
|
|
2735
|
-
* operator semantics live, shared by every
|
|
2152
|
+
* operator semantics live, shared by every event matcher. Returns `false` for a
|
|
2736
2153
|
* missing field so an absent value never matches (fail-closed).
|
|
2737
2154
|
*
|
|
2738
2155
|
* - bare value / `{ eq }` → equality
|
|
@@ -2784,107 +2201,15 @@ function matchesCalls(calls: unknown, op: unknown): boolean {
|
|
|
2784
2201
|
return has(op)
|
|
2785
2202
|
}
|
|
2786
2203
|
|
|
2787
|
-
// RPC window helpers + per-tick lazy cache.
|
|
2788
|
-
|
|
2789
|
-
/** Renders a block number as a `0x`-hex tag for JSON-RPC params. */
|
|
2790
|
-
const blockTag = (n: number) => `0x${n.toString(16)}` as const
|
|
2791
|
-
|
|
2792
|
-
/** Issues a JSON-RPC request through the viem client for a method viem can't type. */
|
|
2793
|
-
function rpc<result>(
|
|
2794
|
-
client: Viem.getClient.ReturnType,
|
|
2795
|
-
method: string,
|
|
2796
|
-
params: readonly unknown[],
|
|
2797
|
-
): Promise<result> {
|
|
2798
|
-
return (client.request as (args: { method: string; params: unknown[] }) => Promise<result>)({
|
|
2799
|
-
method,
|
|
2800
|
-
params: [...params],
|
|
2801
|
-
})
|
|
2802
|
-
}
|
|
2803
|
-
|
|
2804
|
-
/**
|
|
2805
|
-
* The confirmed scan window for a cursor. A cursor `(cb, ci)` means rows through
|
|
2806
|
-
* `(cb, ci)` were dispatched: when `ci` is the end-of-block sentinel the whole
|
|
2807
|
-
* block `cb` is done and scanning resumes at `cb + 1`; otherwise block `cb` is
|
|
2808
|
-
* re-scanned (skipping indices `<= ci`). `to` is capped by both `safeBlock` and
|
|
2809
|
-
* the per-tick backfill cap. Returns `undefined` when the subscription is
|
|
2810
|
-
* already caught up (nothing to scan, cursor unchanged).
|
|
2811
|
-
*/
|
|
2812
|
-
function scanWindow(
|
|
2813
|
-
cursor: string | undefined,
|
|
2814
|
-
safeBlock: number,
|
|
2815
|
-
maxBlocks: number,
|
|
2816
|
-
): { from: number; skip: readonly [number, number]; to: number } | undefined {
|
|
2817
|
-
const decoded = cursor ? Cursor.decode(cursor, ['int', 'int']) : undefined
|
|
2818
|
-
const cb = decoded ? (decoded[0] as number) : 0
|
|
2819
|
-
const ci = decoded ? (decoded[1] as number) : endOfBlockIndex
|
|
2820
|
-
const from = ci >= endOfBlockIndex ? cb + 1 : cb
|
|
2821
|
-
if (from > safeBlock) return undefined
|
|
2822
|
-
const to = Math.min(safeBlock, from + maxBlocks - 1)
|
|
2823
|
-
return { from, skip: [cb, ci], to }
|
|
2824
|
-
}
|
|
2825
|
-
|
|
2826
|
-
/** True when `(block, index)` is at or before the already-dispatched cursor. */
|
|
2827
|
-
function atOrBefore(block: number, index: number, skip: readonly [number, number]): boolean {
|
|
2828
|
-
return block < skip[0] || (block === skip[0] && index <= skip[1])
|
|
2829
|
-
}
|
|
2830
|
-
|
|
2831
2204
|
/** A viem-formatted block header (no transactions), as returned by `getBlock`. */
|
|
2832
2205
|
type RpcBlock = Awaited<ReturnType<Viem.getClient.ReturnType['getBlock']>>
|
|
2833
2206
|
|
|
2834
|
-
|
|
2835
|
-
type ChainCache = {
|
|
2836
|
-
/** Full raw block (with transactions) via `eth_getBlockByNumber(n, true)`. */
|
|
2837
|
-
block(n: number): Promise<Record<string, unknown>>
|
|
2838
|
-
/** Viem-formatted block header (transaction hashes only) via `getBlock`. */
|
|
2839
|
-
header(n: number): Promise<RpcBlock | undefined>
|
|
2840
|
-
}
|
|
2841
|
-
|
|
2842
|
-
/** Builds a per-batch {@link ChainCache} bound to a viem client. */
|
|
2843
|
-
function createChainCache(client: Viem.getClient.ReturnType): ChainCache {
|
|
2844
|
-
const headers = new Map<number, Promise<RpcBlock | undefined>>()
|
|
2845
|
-
const blocks = new Map<number, Promise<Record<string, unknown>>>()
|
|
2846
|
-
|
|
2847
|
-
const singleFlight = <key, value>(
|
|
2848
|
-
pending: Map<key, Promise<value>>,
|
|
2849
|
-
key: key,
|
|
2850
|
-
load: () => Promise<value>,
|
|
2851
|
-
) => {
|
|
2852
|
-
const existing = pending.get(key)
|
|
2853
|
-
if (existing) return existing
|
|
2854
|
-
const promise = load().finally(() => pending.delete(key))
|
|
2855
|
-
pending.set(key, promise)
|
|
2856
|
-
return promise
|
|
2857
|
-
}
|
|
2858
|
-
|
|
2859
|
-
return {
|
|
2860
|
-
block(n) {
|
|
2861
|
-
return singleFlight(blocks, n, () =>
|
|
2862
|
-
// The transaction payload reuses `humanizeTransaction`, which consumes
|
|
2863
|
-
// the raw RPC tx shape, so the full block stays on the untyped path.
|
|
2864
|
-
rpc<Record<string, unknown> | null>(client, 'eth_getBlockByNumber', [
|
|
2865
|
-
blockTag(n),
|
|
2866
|
-
true,
|
|
2867
|
-
]).then((block) => {
|
|
2868
|
-
if (!block) throw new BlockUnavailableError(n)
|
|
2869
|
-
return block
|
|
2870
|
-
}),
|
|
2871
|
-
)
|
|
2872
|
-
},
|
|
2873
|
-
header(n) {
|
|
2874
|
-
return singleFlight(headers, n, () =>
|
|
2875
|
-
client.getBlock({ blockNumber: BigInt(n), includeTransactions: false }),
|
|
2876
|
-
)
|
|
2877
|
-
},
|
|
2878
|
-
}
|
|
2879
|
-
}
|
|
2880
|
-
|
|
2881
|
-
// log:emitted (eth_getLogs)
|
|
2207
|
+
// log:emitted
|
|
2882
2208
|
|
|
2883
2209
|
/**
|
|
2884
|
-
* A viem-
|
|
2885
|
-
*
|
|
2886
|
-
*
|
|
2887
|
-
* `decodeEventLog`/selector-anchoring the scanner used to do is no longer needed.
|
|
2210
|
+
* A viem-shaped log consumed by the log matchers. `args`/`eventName` are present
|
|
2211
|
+
* once {@link decodeScanLog} best-effort decodes the log against a subscription
|
|
2212
|
+
* event signature.
|
|
2888
2213
|
*/
|
|
2889
2214
|
type ScanLog = {
|
|
2890
2215
|
address: string
|
|
@@ -2913,10 +2238,9 @@ type LogEventData = {
|
|
|
2913
2238
|
}
|
|
2914
2239
|
|
|
2915
2240
|
/**
|
|
2916
|
-
* Builds the delivered `log:emitted` payload from a viem-
|
|
2241
|
+
* Builds the delivered `log:emitted` payload from a viem-shaped log plus the
|
|
2917
2242
|
* block timestamp. Returns `undefined` when a required field is missing/invalid
|
|
2918
|
-
* (the row is skipped).
|
|
2919
|
-
* already decoded `args`/`eventName`, so this just serializes them.
|
|
2243
|
+
* (the row is skipped). Decoded `args`/`eventName` are serialized when present.
|
|
2920
2244
|
*/
|
|
2921
2245
|
function toLogEvent(
|
|
2922
2246
|
log: ScanLog,
|
|
@@ -2985,7 +2309,7 @@ function pushHex(op: unknown): `0x${string}` | `0x${string}`[] | undefined {
|
|
|
2985
2309
|
return undefined
|
|
2986
2310
|
}
|
|
2987
2311
|
|
|
2988
|
-
/** Narrows an `eq`/`in` address operator into
|
|
2312
|
+
/** Narrows an `eq`/`in` address operator into a pushdown-capable address value. */
|
|
2989
2313
|
function pushAddress(op: unknown) {
|
|
2990
2314
|
return pushHex(op)
|
|
2991
2315
|
}
|
|
@@ -3001,7 +2325,7 @@ function parseEvent(signature: string | undefined): AbiEvent | undefined {
|
|
|
3001
2325
|
}
|
|
3002
2326
|
}
|
|
3003
2327
|
|
|
3004
|
-
/** Returns exact indexed arguments
|
|
2328
|
+
/** Returns exact indexed arguments usable as a selective log anchor. */
|
|
3005
2329
|
function pushEventArgs(args: Record<string, unknown> | undefined, event: AbiEvent) {
|
|
3006
2330
|
const pushed: Record<string, unknown> = {}
|
|
3007
2331
|
for (const [name, op] of Object.entries(args ?? {})) {
|
|
@@ -3012,7 +2336,7 @@ function pushEventArgs(args: Record<string, unknown> | undefined, event: AbiEven
|
|
|
3012
2336
|
return pushed
|
|
3013
2337
|
}
|
|
3014
2338
|
|
|
3015
|
-
/** Checks that the accepted filter
|
|
2339
|
+
/** Checks that the accepted filter carries a selective anchor rather than matching every log. */
|
|
3016
2340
|
function hasLogPushdown(f: z.output<typeof schema.LogFilters>): boolean {
|
|
3017
2341
|
if (pushAddress(f.address) !== undefined) return true
|
|
3018
2342
|
|
|
@@ -3025,19 +2349,7 @@ function hasLogPushdown(f: z.output<typeof schema.LogFilters>): boolean {
|
|
|
3025
2349
|
return pushHex(f.topic0) !== undefined && secondaryTopic
|
|
3026
2350
|
}
|
|
3027
2351
|
|
|
3028
|
-
/**
|
|
3029
|
-
function pushTopics(f: z.output<typeof schema.LogFilters>, event: AbiEvent | undefined) {
|
|
3030
|
-
const topics: (`0x${string}` | `0x${string}`[] | null | undefined)[] = [
|
|
3031
|
-
event ? encodeEventTopics({ abi: [event] })[0] : pushHex(f.topic0),
|
|
3032
|
-
pushHex(f.topic1),
|
|
3033
|
-
pushHex(f.topic2),
|
|
3034
|
-
pushHex(f.topic3),
|
|
3035
|
-
]
|
|
3036
|
-
while (topics.length > 0 && topics.at(-1) === undefined) topics.pop()
|
|
3037
|
-
return topics.map((topic) => topic ?? null)
|
|
3038
|
-
}
|
|
3039
|
-
|
|
3040
|
-
/** Converts one raw JSON-RPC log into the viem-shaped scanner input. */
|
|
2352
|
+
/** Converts one raw JSON-RPC log into the viem-shaped matcher input. */
|
|
3041
2353
|
function fromRpcLog(log: RpcLog): ScanLog {
|
|
3042
2354
|
return {
|
|
3043
2355
|
address: log.address,
|
|
@@ -3095,78 +2407,6 @@ export declare namespace enrichEventData {
|
|
|
3095
2407
|
}
|
|
3096
2408
|
}
|
|
3097
2409
|
|
|
3098
|
-
/** Scans contract logs for one subscription, then re-checks every filter in memory. */
|
|
3099
|
-
async function scanLogs(options: {
|
|
3100
|
-
batchSize: number
|
|
3101
|
-
cache: ChainCache
|
|
3102
|
-
client: Viem.getClient.ReturnType
|
|
3103
|
-
cursor: string | undefined
|
|
3104
|
-
filters: Record<string, unknown>
|
|
3105
|
-
maxBlocks: number
|
|
3106
|
-
safeBlock: number
|
|
3107
|
-
}): Promise<ScanPage> {
|
|
3108
|
-
const { batchSize, cache, client, cursor, filters, maxBlocks, safeBlock } = options
|
|
3109
|
-
const f = filters as z.output<typeof schema.LogFilters>
|
|
3110
|
-
const window = scanWindow(cursor, safeBlock, maxBlocks)
|
|
3111
|
-
if (!window) return { items: [], scannedThrough: undefined }
|
|
3112
|
-
|
|
3113
|
-
const event = parseEvent(f.signature)
|
|
3114
|
-
if (f.signature && (!event || !/^[A-Za-z_]\w*$/.test(event.name)))
|
|
3115
|
-
throw new Webhooks.InvalidFilterError('log:emitted', [
|
|
3116
|
-
{ message: 'Unsupported event signature.', path: ['signature'] },
|
|
3117
|
-
])
|
|
3118
|
-
|
|
3119
|
-
const address = pushAddress(f.address)
|
|
3120
|
-
const topics = pushTopics(f, event)
|
|
3121
|
-
const hasRawTopics = topics.slice(1).some((topic) => topic !== null)
|
|
3122
|
-
const args = event ? pushEventArgs(f.args, event) : {}
|
|
3123
|
-
const logs = (!event || hasRawTopics
|
|
3124
|
-
? (
|
|
3125
|
-
await rpc<RpcLog[]>(client, 'eth_getLogs', [
|
|
3126
|
-
{
|
|
3127
|
-
...(address ? { address } : {}),
|
|
3128
|
-
fromBlock: blockTag(window.from),
|
|
3129
|
-
...(topics.length > 0 ? { topics } : {}),
|
|
3130
|
-
toBlock: blockTag(window.to),
|
|
3131
|
-
},
|
|
3132
|
-
])
|
|
3133
|
-
)
|
|
3134
|
-
.map(fromRpcLog)
|
|
3135
|
-
.map((log) => (event ? decodeScanLog(log, event) : log))
|
|
3136
|
-
: await client.getLogs({
|
|
3137
|
-
...(address ? { address } : {}),
|
|
3138
|
-
...(Object.keys(args).length > 0 ? { args: args as never } : {}),
|
|
3139
|
-
event,
|
|
3140
|
-
fromBlock: BigInt(window.from),
|
|
3141
|
-
strict: false,
|
|
3142
|
-
toBlock: BigInt(window.to),
|
|
3143
|
-
})) as unknown as ScanLog[]
|
|
3144
|
-
logs.sort((a, b) => {
|
|
3145
|
-
const block = Number(a.blockNumber ?? 0n) - Number(b.blockNumber ?? 0n)
|
|
3146
|
-
return block || (a.logIndex ?? 0) - (b.logIndex ?? 0)
|
|
3147
|
-
})
|
|
3148
|
-
|
|
3149
|
-
const items: ScanItem[] = []
|
|
3150
|
-
for (const log of logs) {
|
|
3151
|
-
const blockNumber = log.blockNumber === null ? undefined : Number(log.blockNumber)
|
|
3152
|
-
const logIndex = log.logIndex ?? undefined
|
|
3153
|
-
if (blockNumber === undefined || logIndex === undefined) continue
|
|
3154
|
-
if (atOrBefore(blockNumber, logIndex, window.skip)) continue
|
|
3155
|
-
if (!matchesLog(log, f, event)) continue
|
|
3156
|
-
const data = toLogEvent(
|
|
3157
|
-
log,
|
|
3158
|
-
Value.blockToIso(await cache.header(blockNumber)),
|
|
3159
|
-
f.signature,
|
|
3160
|
-
event,
|
|
3161
|
-
)
|
|
3162
|
-
if (data) {
|
|
3163
|
-
items.push({ cursor: [data.blockNumber, data.logIndex], data })
|
|
3164
|
-
if (items.length >= batchSize) return { hasMore: true, items }
|
|
3165
|
-
}
|
|
3166
|
-
}
|
|
3167
|
-
return { items, scannedThrough: window.to }
|
|
3168
|
-
}
|
|
3169
|
-
|
|
3170
2410
|
/** Re-checks all log filters (including non-pushable operators) in JS. */
|
|
3171
2411
|
function matchesLog(
|
|
3172
2412
|
log: ScanLog,
|
|
@@ -3184,15 +2424,15 @@ function matchesLog(
|
|
|
3184
2424
|
!matchesOperator(log.blockNumber?.toString(), f.blockNumber, 'number')
|
|
3185
2425
|
)
|
|
3186
2426
|
return false
|
|
3187
|
-
//
|
|
3188
|
-
// `topic0` operator only applies in raw (no-signature) mode.
|
|
2427
|
+
// With a signature, topic0 is anchored on the event selector above, so the
|
|
2428
|
+
// raw `topic0` operator only applies in raw (no-signature) mode.
|
|
3189
2429
|
if (!event && f.topic0 !== undefined && !matchesOperator(topics[0], f.topic0)) return false
|
|
3190
2430
|
if (f.topic1 !== undefined && !matchesOperator(topics[1], f.topic1)) return false
|
|
3191
2431
|
if (f.topic2 !== undefined && !matchesOperator(topics[2], f.topic2)) return false
|
|
3192
2432
|
if (f.topic3 !== undefined && !matchesOperator(topics[3], f.topic3)) return false
|
|
3193
2433
|
|
|
3194
2434
|
if (event && f.args && Object.keys(f.args).length > 0) {
|
|
3195
|
-
//
|
|
2435
|
+
// Best-effort decoding leaves a non-decodable log without `args`.
|
|
3196
2436
|
if (log.args === undefined || Array.isArray(log.args)) return false
|
|
3197
2437
|
const args = log.args as Record<string, unknown>
|
|
3198
2438
|
for (const [name, op] of Object.entries(f.args)) {
|
|
@@ -3210,7 +2450,7 @@ function matchesLog(
|
|
|
3210
2450
|
return true
|
|
3211
2451
|
}
|
|
3212
2452
|
|
|
3213
|
-
// block:created
|
|
2453
|
+
// block:created
|
|
3214
2454
|
|
|
3215
2455
|
type BlockEventData = {
|
|
3216
2456
|
number: number
|
|
@@ -3255,35 +2495,6 @@ function toBlockEvent(block: RpcBlock): BlockEventData | undefined {
|
|
|
3255
2495
|
}
|
|
3256
2496
|
}
|
|
3257
2497
|
|
|
3258
|
-
/** Scans new blocks over one subscription's confirmed window. */
|
|
3259
|
-
async function scanBlocks(options: {
|
|
3260
|
-
batchSize: number
|
|
3261
|
-
cache: ChainCache
|
|
3262
|
-
cursor: string | undefined
|
|
3263
|
-
filters: Record<string, unknown>
|
|
3264
|
-
maxBlocks: number
|
|
3265
|
-
safeBlock: number
|
|
3266
|
-
}): Promise<ScanPage> {
|
|
3267
|
-
const { batchSize, cache, cursor, filters, maxBlocks, safeBlock } = options
|
|
3268
|
-
const f = filters as z.output<typeof schema.BlockFilters>
|
|
3269
|
-
const window = scanWindow(cursor, safeBlock, maxBlocks)
|
|
3270
|
-
if (!window) return { items: [], scannedThrough: undefined }
|
|
3271
|
-
|
|
3272
|
-
const items: ScanItem[] = []
|
|
3273
|
-
for (let blockNumber = window.from; blockNumber <= window.to; blockNumber++) {
|
|
3274
|
-
if (atOrBefore(blockNumber, 0, window.skip)) continue
|
|
3275
|
-
const block = await cache.header(blockNumber)
|
|
3276
|
-
if (!block) continue
|
|
3277
|
-
if (!matchesBlock(block, f)) continue
|
|
3278
|
-
const data = toBlockEvent(block)
|
|
3279
|
-
if (data) {
|
|
3280
|
-
items.push({ cursor: [data.number, 0], data })
|
|
3281
|
-
if (items.length >= batchSize) return { hasMore: true, items }
|
|
3282
|
-
}
|
|
3283
|
-
}
|
|
3284
|
-
return { items, scannedThrough: window.to }
|
|
3285
|
-
}
|
|
3286
|
-
|
|
3287
2498
|
/** Re-checks all block filters in JS. The RPC has no consensus `proposer`. */
|
|
3288
2499
|
function matchesBlock(block: RpcBlock, f: z.output<typeof schema.BlockFilters>): boolean {
|
|
3289
2500
|
if (f.number !== undefined && !matchesOperator(block.number?.toString(), f.number, 'number'))
|
|
@@ -3306,7 +2517,7 @@ function matchesBlock(block: RpcBlock, f: z.output<typeof schema.BlockFilters>):
|
|
|
3306
2517
|
return true
|
|
3307
2518
|
}
|
|
3308
2519
|
|
|
3309
|
-
// transaction:included
|
|
2520
|
+
// transaction:included
|
|
3310
2521
|
|
|
3311
2522
|
/** Maps a `transaction:included` scalar filter field to its raw-tx field + kind. */
|
|
3312
2523
|
const txFields = {
|
|
@@ -3324,47 +2535,6 @@ const txFields = {
|
|
|
3324
2535
|
value: { field: 'value', kind: 'number' },
|
|
3325
2536
|
} as const satisfies Record<string, { field: string; kind: 'number' | 'string' }>
|
|
3326
2537
|
|
|
3327
|
-
/** Scans included transactions over one subscription's confirmed window. */
|
|
3328
|
-
async function scanTxs(options: {
|
|
3329
|
-
batchSize: number
|
|
3330
|
-
cache: ChainCache
|
|
3331
|
-
cursor: string | undefined
|
|
3332
|
-
filters: Record<string, unknown>
|
|
3333
|
-
maxBlocks: number
|
|
3334
|
-
safeBlock: number
|
|
3335
|
-
}): Promise<ScanPage> {
|
|
3336
|
-
const { batchSize, cache, cursor, filters, maxBlocks, safeBlock } = options
|
|
3337
|
-
const f = filters as z.output<typeof schema.TxFilters>
|
|
3338
|
-
const window = scanWindow(cursor, safeBlock, maxBlocks)
|
|
3339
|
-
if (!window) return { items: [], scannedThrough: undefined }
|
|
3340
|
-
|
|
3341
|
-
const includeCalls = f.includeCalls ?? true
|
|
3342
|
-
const items: ScanItem[] = []
|
|
3343
|
-
for (let blockNumber = window.from; blockNumber <= window.to; blockNumber++) {
|
|
3344
|
-
const block = await cache.block(blockNumber)
|
|
3345
|
-
const transactions = Array.isArray(block['transactions'])
|
|
3346
|
-
? (block['transactions'] as unknown[])
|
|
3347
|
-
: []
|
|
3348
|
-
const timestamp = asString(block['timestamp'])
|
|
3349
|
-
// A transaction's own `blockTimestamp` is seconds-only, so the delivered
|
|
3350
|
-
// timestamp comes from the enclosing block, which carries milliseconds.
|
|
3351
|
-
const blockTime = Value.blockToIso(block)
|
|
3352
|
-
for (let offset = 0; offset < transactions.length; offset++) {
|
|
3353
|
-
const transaction = transactions[offset] as Record<string, unknown>
|
|
3354
|
-
const transactionIndex = Value.hexToNumber(transaction['transactionIndex']) ?? offset
|
|
3355
|
-
if (atOrBefore(blockNumber, transactionIndex, window.skip)) continue
|
|
3356
|
-
if (!matchesTx(transaction, f, includeCalls, timestamp, blockNumber)) continue
|
|
3357
|
-
const parsed = Transactions.schema.Rpc.Transaction.safeParse(transaction)
|
|
3358
|
-
if (!parsed.success) continue
|
|
3359
|
-
const humanized = Transactions.humanizeTransaction(parsed.data)
|
|
3360
|
-
const data = blockTime === undefined ? humanized : { ...humanized, timestamp: blockTime }
|
|
3361
|
-
items.push({ cursor: [blockNumber, transactionIndex], data })
|
|
3362
|
-
if (items.length >= batchSize) return { hasMore: true, items }
|
|
3363
|
-
}
|
|
3364
|
-
}
|
|
3365
|
-
return { items, scannedThrough: window.to }
|
|
3366
|
-
}
|
|
3367
|
-
|
|
3368
2538
|
/** Re-checks transaction filters against the raw RPC transaction. */
|
|
3369
2539
|
function matchesTx(
|
|
3370
2540
|
tx: Record<string, unknown>,
|
|
@@ -3420,6 +2590,16 @@ function ownerFor(c: Context<App.Environment>): Webhooks.Owner | null {
|
|
|
3420
2590
|
return payer ? { payer, type: 'mpp' } : null
|
|
3421
2591
|
}
|
|
3422
2592
|
|
|
2593
|
+
function accessFor(c: Context<App.Environment>): Webhooks.Access {
|
|
2594
|
+
const principal = Auth.getPrincipal(c)
|
|
2595
|
+
if (principal?.type !== 'api_key') throw new Error('Webhook routes require an API key.')
|
|
2596
|
+
return {
|
|
2597
|
+
environment: principal.environment,
|
|
2598
|
+
...(principal.projectId === undefined ? {} : { projectId: principal.projectId }),
|
|
2599
|
+
scopes: principal.apiKey.scopes,
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
|
|
3423
2603
|
/** Strips internal fields (`owner`, `secret`) from a subscription for reads. */
|
|
3424
2604
|
function toPublic(subscription: Webhooks.Subscription) {
|
|
3425
2605
|
return {
|
|
@@ -3427,6 +2607,10 @@ function toPublic(subscription: Webhooks.Subscription) {
|
|
|
3427
2607
|
...(subscription.context === undefined ? {} : { context: subscription.context }),
|
|
3428
2608
|
createdAt: subscription.createdAt,
|
|
3429
2609
|
destination: redactDestination(subscription.destination),
|
|
2610
|
+
...(subscription.eventType === 'funding:deposit.updated' &&
|
|
2611
|
+
subscription.environment !== undefined
|
|
2612
|
+
? { environment: subscription.environment }
|
|
2613
|
+
: {}),
|
|
3430
2614
|
eventType: subscription.eventType,
|
|
3431
2615
|
...(subscription.expiresAt === undefined ? {} : { expiresAt: subscription.expiresAt }),
|
|
3432
2616
|
failureCount: subscription.failureCount,
|
|
@@ -3709,11 +2893,3 @@ export declare namespace scanBlock {
|
|
|
3709
2893
|
matches: readonly Match<subscription>[]
|
|
3710
2894
|
}
|
|
3711
2895
|
}
|
|
3712
|
-
|
|
3713
|
-
class BlockUnavailableError extends Error {
|
|
3714
|
-
override name = 'Webhooks.BlockUnavailableError'
|
|
3715
|
-
|
|
3716
|
-
constructor(blockNumber: number) {
|
|
3717
|
-
super(`Block ${blockNumber} is unavailable.`)
|
|
3718
|
-
}
|
|
3719
|
-
}
|