tapimo 0.5.1 → 0.5.2

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 (114) hide show
  1. package/dist/App.d.ts +34 -18
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/admin/apps/verified-tokens.d.ts +117 -117
  4. package/dist/apps/data/App.d.ts +3217 -3217
  5. package/dist/apps/data/FundingQuoteProviders.d.ts +32 -0
  6. package/dist/apps/data/FundingQuoteProviders.d.ts.map +1 -1
  7. package/dist/apps/data/FundingQuoteProviders.js +134 -1
  8. package/dist/apps/data/FundingQuoteProviders.js.map +1 -1
  9. package/dist/apps/data/routes/addresses.d.ts +206 -206
  10. package/dist/apps/data/routes/balances.d.ts +46 -46
  11. package/dist/apps/data/routes/coingecko.d.ts +169 -169
  12. package/dist/apps/data/routes/funding.d.ts +8 -2
  13. package/dist/apps/data/routes/funding.d.ts.map +1 -1
  14. package/dist/apps/data/routes/funding.js +4 -4
  15. package/dist/apps/data/routes/funding.js.map +1 -1
  16. package/dist/apps/data/routes/indexer.d.ts +34 -34
  17. package/dist/apps/data/routes/receipts.d.ts +228 -228
  18. package/dist/apps/data/routes/rpc.d.ts +31 -31
  19. package/dist/apps/data/routes/transfers.d.ts +8 -15
  20. package/dist/apps/data/routes/transfers.d.ts.map +1 -1
  21. package/dist/apps/data/routes/transfers.js +21 -27
  22. package/dist/apps/data/routes/transfers.js.map +1 -1
  23. package/dist/apps/data/routes/webhooks.d.ts +35 -35
  24. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  25. package/dist/apps/data/routes/webhooks.js +176 -42
  26. package/dist/apps/data/routes/webhooks.js.map +1 -1
  27. package/dist/apps/management/App.d.ts +982 -966
  28. package/dist/apps/management/App.d.ts.map +1 -1
  29. package/dist/apps/management/Billing.d.ts.map +1 -1
  30. package/dist/apps/management/Billing.js +5 -4
  31. package/dist/apps/management/Billing.js.map +1 -1
  32. package/dist/apps/management/routes/billing.d.ts.map +1 -1
  33. package/dist/apps/management/routes/billing.js +1 -1
  34. package/dist/apps/management/routes/billing.js.map +1 -1
  35. package/dist/apps/management/routes/invitations.d.ts +121 -121
  36. package/dist/apps/management/routes/invite-links.d.ts +174 -174
  37. package/dist/apps/management/routes/orgs.d.ts +102 -86
  38. package/dist/apps/management/routes/orgs.d.ts.map +1 -1
  39. package/dist/apps/management/routes/orgs.js +18 -1
  40. package/dist/apps/management/routes/orgs.js.map +1 -1
  41. package/dist/apps/management/routes/projects.d.ts +124 -124
  42. package/dist/apps/relay/Sponsorships.d.ts +3 -0
  43. package/dist/apps/relay/Sponsorships.d.ts.map +1 -1
  44. package/dist/apps/relay/Sponsorships.js +15 -1
  45. package/dist/apps/relay/Sponsorships.js.map +1 -1
  46. package/dist/db/Db.d.ts.map +1 -1
  47. package/dist/db/Db.js +4 -0
  48. package/dist/db/Db.js.map +1 -1
  49. package/dist/db/Schema.js +1 -1
  50. package/dist/db/Schema.js.map +1 -1
  51. package/dist/db/tables/organizations.d.ts +6 -0
  52. package/dist/db/tables/organizations.d.ts.map +1 -1
  53. package/dist/db/tables/organizations.js +26 -0
  54. package/dist/db/tables/organizations.js.map +1 -1
  55. package/dist/db/tables/requestUsage.d.ts +2 -11
  56. package/dist/db/tables/requestUsage.d.ts.map +1 -1
  57. package/dist/db/tables/requestUsage.js +1 -20
  58. package/dist/db/tables/requestUsage.js.map +1 -1
  59. package/dist/db/tables/sponsoredTransactions.d.ts +2 -1
  60. package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
  61. package/dist/db/tables/sponsoredTransactions.js +46 -8
  62. package/dist/db/tables/sponsoredTransactions.js.map +1 -1
  63. package/dist/internal/Auth.d.ts +4 -0
  64. package/dist/internal/Auth.d.ts.map +1 -1
  65. package/dist/internal/Auth.js +7 -8
  66. package/dist/internal/Auth.js.map +1 -1
  67. package/dist/internal/Cache.d.ts.map +1 -1
  68. package/dist/internal/Cache.js +9 -1
  69. package/dist/internal/Cache.js.map +1 -1
  70. package/dist/internal/EdgeCache.d.ts.map +1 -1
  71. package/dist/internal/EdgeCache.js +12 -3
  72. package/dist/internal/EdgeCache.js.map +1 -1
  73. package/dist/internal/FundingQuotes.d.ts +16 -10
  74. package/dist/internal/FundingQuotes.d.ts.map +1 -1
  75. package/dist/internal/FundingQuotes.js +9 -0
  76. package/dist/internal/FundingQuotes.js.map +1 -1
  77. package/dist/internal/RateLimit.d.ts +2 -0
  78. package/dist/internal/RateLimit.d.ts.map +1 -1
  79. package/dist/internal/RateLimit.js +4 -4
  80. package/dist/internal/RateLimit.js.map +1 -1
  81. package/package.json +6 -1
  82. package/src/App.test.ts +2 -2
  83. package/src/Client.test-d.ts +28 -2
  84. package/src/apps/data/FundingQuoteProviders.test.ts +184 -0
  85. package/src/apps/data/FundingQuoteProviders.ts +185 -1
  86. package/src/apps/data/routes/funding.test.ts +7 -0
  87. package/src/apps/data/routes/funding.ts +4 -4
  88. package/src/apps/data/routes/tokens.test.ts +54 -0
  89. package/src/apps/data/routes/transfers.test.ts +73 -1
  90. package/src/apps/data/routes/transfers.ts +24 -30
  91. package/src/apps/data/routes/webhooks.test.ts +203 -0
  92. package/src/apps/data/routes/webhooks.ts +191 -54
  93. package/src/apps/management/Billing.test.ts +50 -1
  94. package/src/apps/management/Billing.ts +5 -4
  95. package/src/apps/management/routes/billing.ts +6 -1
  96. package/src/apps/management/routes/orgs.ts +19 -1
  97. package/src/apps/relay/Sponsorships.ts +24 -1
  98. package/src/db/Db.ts +4 -0
  99. package/src/db/Schema.ts +1 -1
  100. package/src/db/tables/organizations.test.ts +30 -0
  101. package/src/db/tables/organizations.ts +28 -0
  102. package/src/db/tables/requestUsage.ts +3 -23
  103. package/src/db/tables/sponsoredTransactions.test.ts +41 -0
  104. package/src/db/tables/sponsoredTransactions.ts +46 -7
  105. package/src/internal/Auth.test.ts +63 -10
  106. package/src/internal/Auth.ts +11 -9
  107. package/src/internal/Cache.test.ts +19 -0
  108. package/src/internal/Cache.ts +13 -1
  109. package/src/internal/EdgeCache.test.ts +40 -0
  110. package/src/internal/EdgeCache.ts +11 -3
  111. package/src/internal/FundingQuotes.test.ts +33 -0
  112. package/src/internal/FundingQuotes.ts +9 -0
  113. package/src/internal/RateLimit.test.ts +12 -0
  114. package/src/internal/RateLimit.ts +4 -4
@@ -1,5 +1,5 @@
1
1
  import { type Context, Hono } from 'hono'
2
- import { type AbiEvent, parseAbiItem } from 'viem'
2
+ import { type AbiEvent, decodeEventLog, encodeEventTopics, parseAbiItem, type RpcLog } from 'viem'
3
3
  import * as z from 'zod/mini'
4
4
 
5
5
  import type * as App from '../../../App.js'
@@ -15,6 +15,7 @@ import type * as Tidx from '../../../internal/Tidx.js'
15
15
  import * as Ttl from '../../../internal/Ttl.js'
16
16
  import * as Value from '../../../internal/Value.js'
17
17
  import type * as Viem from '../../../internal/Viem.js'
18
+ import type * as WebhookDestination from '../../../internal/WebhookDestination.js'
18
19
  import * as Webhooks from '../../../internal/Webhooks.js'
19
20
  import * as Receipts from './receipts.js'
20
21
  import * as Transactions from './transactions.js'
@@ -30,6 +31,9 @@ const defaultMaxPerOwner = 100
30
31
  */
31
32
  const mppTtlMs = 30 * 24 * 60 * 60 * 1_000
32
33
 
34
+ const redactedBetterstackToken = '[redacted]'
35
+ const redactedSlackUrl = 'https://hooks.slack.com/…'
36
+
33
37
  /**
34
38
  * The subscribable webhook event types, in alphabetical order. The source of
35
39
  * truth for the event-type *set*: the {@link EventType} union and the route zod
@@ -97,12 +101,11 @@ export namespace schema {
97
101
  z.meta({ examples: ['https://example.com/webhooks'] }),
98
102
  )
99
103
 
100
- const SlackUrl = z
101
- .url()
102
- .check(
103
- z.describe('Your Slack incoming-webhook URL (`https://hooks.slack.com/services/…`).'),
104
- z.meta({ examples: ['https://hooks.slack.com/services/T000/B000/XXXXXXXX'] }),
105
- )
104
+ const SlackUrl = z.url().check(
105
+ z.refine((url) => url !== redactedSlackUrl, { error: 'A Slack webhook URL is required.' }),
106
+ z.describe('Your Slack incoming-webhook URL (`https://hooks.slack.com/services/…`).'),
107
+ z.meta({ examples: ['https://hooks.slack.com/services/T000/B000/XXXXXXXX'] }),
108
+ )
106
109
 
107
110
  const BetterstackUrl = z
108
111
  .url()
@@ -113,13 +116,14 @@ export namespace schema {
113
116
  z.meta({ examples: ['https://s1234567.eu-nbg-2.betterstackdata.com'] }),
114
117
  )
115
118
 
116
- const BetterstackToken = z
117
- .string()
118
- .check(
119
- z.minLength(1),
120
- z.describe('Your Better Stack source token (sent as `Authorization: Bearer …`).'),
121
- z.meta({ examples: ['FczKcxEhjEDE58dBX7XaeX1q'] }),
122
- )
119
+ const BetterstackToken = z.string().check(
120
+ z.minLength(1),
121
+ z.refine((token) => token !== redactedBetterstackToken, {
122
+ error: 'A Better Stack source token is required.',
123
+ }),
124
+ z.describe('Your Better Stack source token (sent as `Authorization: Bearer …`).'),
125
+ z.meta({ examples: ['FczKcxEhjEDE58dBX7XaeX1q'] }),
126
+ )
123
127
 
124
128
  /**
125
129
  * Where matched events are delivered: a `url` destination (a signed event POST
@@ -149,6 +153,26 @@ export namespace schema {
149
153
  ),
150
154
  )
151
155
 
156
+ const PublicDestination = z
157
+ .discriminatedUnion('type', [
158
+ z.object({
159
+ type: z.literal('url'),
160
+ url: Url,
161
+ }),
162
+ z.object({
163
+ type: z.literal('slack'),
164
+ url: z.literal(redactedSlackUrl).check(z.describe('Redacted Slack webhook URL.')),
165
+ }),
166
+ z.object({
167
+ token: z
168
+ .literal(redactedBetterstackToken)
169
+ .check(z.describe('Redacted Better Stack source token.')),
170
+ type: z.literal('betterstack'),
171
+ url: BetterstackUrl,
172
+ }),
173
+ ])
174
+ .check(z.describe('Delivery destination with bearer credentials redacted.'))
175
+
152
176
  /**
153
177
  * Equality / membership operator for a filterable field. A consumer can pass a
154
178
  * bare value (shorthand for exact equality, the common case) or an operator
@@ -399,15 +423,10 @@ export namespace schema {
399
423
  z.refine((f) => !f.args || f.signature, {
400
424
  error: '`args` requires `signature` to decode the event.',
401
425
  }),
402
- z.refine(
403
- (f) =>
404
- Boolean(f.address) ||
405
- (Boolean(f.signature || f.topic0) && Boolean(f.topic1 || f.topic2 || f.topic3 || f.args)),
406
- {
407
- error:
408
- 'Addressless log filters must include an event signature or topic0 plus at least one indexed topic or argument.',
409
- },
410
- ),
426
+ z.refine((f) => hasLogPushdown(f), {
427
+ error:
428
+ 'Log filters must include a pushdown-capable address or an event/topic0 anchor plus an indexed topic or argument.',
429
+ }),
411
430
  )
412
431
 
413
432
  /**
@@ -507,7 +526,7 @@ export namespace schema {
507
526
  z.describe('When the subscription was created, as an ISO 8601 timestamp.'),
508
527
  z.meta({ examples: ['2024-01-01T00:00:00Z'] }),
509
528
  ),
510
- destination: Destination,
529
+ destination: PublicDestination,
511
530
  eventType: EventType.check(z.meta({ examples: ['token:transfer'] })),
512
531
  expiresAt: z
513
532
  .optional(z.iso.datetime())
@@ -1032,7 +1051,9 @@ export namespace schema {
1032
1051
  .check(z.describe('Details for creating a webhook subscription.'))
1033
1052
 
1034
1053
  export const Response = z
1035
- .extend(Subscription, {
1054
+ .object({
1055
+ ...Subscription.shape,
1056
+ destination: Destination,
1036
1057
  secret: z
1037
1058
  .string()
1038
1059
  .check(
@@ -2294,6 +2315,7 @@ export function matchesOperator(
2294
2315
  return value.toLowerCase().startsWith(String(o['startsWith']).toLowerCase())
2295
2316
  if ('in' in o) return (o['in'] as readonly unknown[]).some((v) => operandEquals(value, v, kind))
2296
2317
  if ('not' in o) return !operandEquals(value, o['not'], kind)
2318
+ if ('eq' in o && kind === 'string') return operandEquals(value, o['eq'], kind)
2297
2319
  // Comparison object: AND-combine whichever of eq/gt/gte/lt/lte are present.
2298
2320
  const n = toBig(value)
2299
2321
  let ok = true
@@ -2510,31 +2532,124 @@ function toLogEvent(
2510
2532
  }
2511
2533
  }
2512
2534
 
2535
+ /** Narrows an exact or membership operator into an RPC filter value. */
2536
+ function pushValue(op: unknown): unknown {
2537
+ const value = (() => {
2538
+ if (typeof op !== 'object' || op === null) return op
2539
+ const operator = op as Record<string, unknown>
2540
+ if ('eq' in operator) return operator['eq']
2541
+ if (Array.isArray(operator['in']) && operator['in'].length > 0) return operator['in']
2542
+ return undefined
2543
+ })()
2544
+ const scalar = (item: unknown) => ['bigint', 'boolean', 'number', 'string'].includes(typeof item)
2545
+ if (scalar(value)) return value
2546
+ if (Array.isArray(value) && value.length > 0 && value.every(scalar)) return value
2547
+ return undefined
2548
+ }
2549
+
2550
+ /** Narrows an `eq`/`in` operator into a hex RPC filter value. */
2551
+ function pushHex(op: unknown): `0x${string}` | `0x${string}`[] | undefined {
2552
+ const value = pushValue(op)
2553
+ if (typeof value === 'string') return value as `0x${string}`
2554
+ if (Array.isArray(value) && value.every((item) => typeof item === 'string'))
2555
+ return value as `0x${string}`[]
2556
+ return undefined
2557
+ }
2558
+
2513
2559
  /** Narrows an `eq`/`in` address operator into the `getLogs` address pushdown. */
2514
- function pushAddress(op: unknown): `0x${string}` | `0x${string}`[] | undefined {
2515
- if (op === undefined) return undefined
2516
- if (typeof op === 'string') return op as `0x${string}`
2517
- if (typeof op === 'object' && op !== null) {
2518
- const o = op as Record<string, unknown>
2519
- if (typeof o['eq'] === 'string') return o['eq'] as `0x${string}`
2520
- if (Array.isArray(o['in']))
2521
- return o['in'].filter((v): v is string => typeof v === 'string') as `0x${string}`[]
2560
+ function pushAddress(op: unknown) {
2561
+ return pushHex(op)
2562
+ }
2563
+
2564
+ /** Parses a human-readable event signature without letting refinements throw. */
2565
+ function parseEvent(signature: string | undefined): AbiEvent | undefined {
2566
+ if (!signature) return undefined
2567
+ try {
2568
+ const item = parseAbiItem(signature)
2569
+ return item.type === 'event' ? item : undefined
2570
+ } catch {
2571
+ return undefined
2572
+ }
2573
+ }
2574
+
2575
+ /** Returns exact indexed arguments that viem can push into `eth_getLogs`. */
2576
+ function pushEventArgs(args: Record<string, unknown> | undefined, event: AbiEvent) {
2577
+ const pushed: Record<string, unknown> = {}
2578
+ for (const [name, op] of Object.entries(args ?? {})) {
2579
+ const input = event.inputs.find((item) => item.indexed && item.name === name)
2580
+ const value = pushValue(op)
2581
+ if (input && value !== undefined) pushed[name] = value
2582
+ }
2583
+ return pushed
2584
+ }
2585
+
2586
+ /** Checks that the accepted filter has a selective `eth_getLogs` representation. */
2587
+ function hasLogPushdown(f: z.output<typeof schema.LogFilters>): boolean {
2588
+ if (pushAddress(f.address) !== undefined) return true
2589
+
2590
+ const secondaryTopic = [f.topic1, f.topic2, f.topic3].some(
2591
+ (topic) => pushHex(topic) !== undefined,
2592
+ )
2593
+ const event = parseEvent(f.signature)
2594
+ if (event && !event.anonymous)
2595
+ return secondaryTopic || Object.keys(pushEventArgs(f.args, event)).length > 0
2596
+ return pushHex(f.topic0) !== undefined && secondaryTopic
2597
+ }
2598
+
2599
+ /** Builds raw topic filters, preserving null wildcards between constrained positions. */
2600
+ function pushTopics(f: z.output<typeof schema.LogFilters>, event: AbiEvent | undefined) {
2601
+ const topics: (`0x${string}` | `0x${string}`[] | null | undefined)[] = [
2602
+ event ? encodeEventTopics({ abi: [event] })[0] : pushHex(f.topic0),
2603
+ pushHex(f.topic1),
2604
+ pushHex(f.topic2),
2605
+ pushHex(f.topic3),
2606
+ ]
2607
+ while (topics.at(-1) === undefined) topics.pop()
2608
+ return topics.map((topic) => topic ?? null)
2609
+ }
2610
+
2611
+ /** Converts one raw JSON-RPC log into the viem-shaped scanner input. */
2612
+ function fromRpcLog(log: RpcLog): ScanLog {
2613
+ return {
2614
+ address: log.address,
2615
+ blockNumber: log.blockNumber === null ? null : BigInt(log.blockNumber),
2616
+ data: log.data,
2617
+ logIndex: Value.hexToNumber(log.logIndex) ?? null,
2618
+ topics: log.topics,
2619
+ transactionHash: log.transactionHash,
2620
+ transactionIndex: Value.hexToNumber(log.transactionIndex) ?? null,
2621
+ }
2622
+ }
2623
+
2624
+ /** Best-effort decodes a raw log using the subscription event. */
2625
+ function decodeScanLog(log: ScanLog, event: AbiEvent): ScanLog {
2626
+ try {
2627
+ const decoded = decodeEventLog({
2628
+ abi: [event],
2629
+ data: log.data as `0x${string}`,
2630
+ strict: false,
2631
+ topics: log.topics as [`0x${string}`, ...`0x${string}`[]],
2632
+ })
2633
+ return {
2634
+ ...log,
2635
+ args: decoded.args,
2636
+ eventName: decoded.eventName,
2637
+ }
2638
+ } catch {
2639
+ return log
2522
2640
  }
2523
- // `not`/range forms can't be pushed; widen here, re-check in JS.
2524
- return undefined
2525
2641
  }
2526
2642
 
2527
2643
  /**
2528
- * Scans contract event logs over the confirmed window via viem's `getLogs`,
2529
- * then re-checks **every** filter in JS (so operators the RPC can't express
2530
- * `not`, ranges, decoded `args` still apply). Two modes:
2644
+ * Scans contract event logs over the confirmed window via `eth_getLogs`,
2645
+ * then re-checks **every** filter in JS. Operators the RPC cannot express
2646
+ * (`not`, ranges, non-indexed `args`) still apply. Two modes:
2531
2647
  * - **raw** (no `signature`): `topic0..3` are matched directly.
2532
- * - **decoded** (`signature` present): `getLogs` is given the `event`, so it
2533
- * pushes the event selector down, filters to matching logs, and best-effort
2534
- * decodes `args` — which the JS pass then matches by name.
2648
+ * - **decoded** (`signature` present): the selector, raw topics, and supported
2649
+ * indexed `args` are pushed down before the JS pass matches decoded values.
2535
2650
  *
2536
- * `address` (`eq`/`in`) is pushed down to the node; all other operators are
2537
- * re-checked in JS over the narrow window.
2651
+ * Exact and membership address/topic operators are pushed down to the node;
2652
+ * all operators are re-checked in JS over the narrow window.
2538
2653
  */
2539
2654
  async function scanLogs(options: {
2540
2655
  cache: ChainCache
@@ -2549,8 +2664,8 @@ async function scanLogs(options: {
2549
2664
  const window = scanWindow(cursor, safeBlock, maxBlocks)
2550
2665
  if (!window) return { items: [], scannedThrough: undefined }
2551
2666
 
2552
- const event = f.signature ? (parseAbiItem(f.signature) as AbiEvent) : undefined
2553
- if (event && !/^[A-Za-z_]\w*$/.test(event.name))
2667
+ const event = parseEvent(f.signature)
2668
+ if (f.signature && (!event || !/^[A-Za-z_]\w*$/.test(event.name)))
2554
2669
  throw new Webhooks.InvalidFilterError('log:emitted', [
2555
2670
  { message: 'Unsupported event signature.', path: ['signature'] },
2556
2671
  ])
@@ -2558,18 +2673,29 @@ async function scanLogs(options: {
2558
2673
  const address = pushAddress(f.address)
2559
2674
  const fromBlock = BigInt(window.from)
2560
2675
  const toBlock = BigInt(window.to)
2561
- const logs = (event
2562
- ? await client.getLogs({
2676
+ const topics = pushTopics(f, event)
2677
+ const hasRawTopics = topics.slice(1).some((topic) => topic !== null)
2678
+ const args = event ? pushEventArgs(f.args, event) : {}
2679
+ const logs = (!event || hasRawTopics
2680
+ ? (
2681
+ await rpc<RpcLog[]>(client, 'eth_getLogs', [
2682
+ {
2683
+ ...(address ? { address } : {}),
2684
+ fromBlock: blockTag(window.from),
2685
+ ...(topics.length > 0 ? { topics } : {}),
2686
+ toBlock: blockTag(window.to),
2687
+ },
2688
+ ])
2689
+ )
2690
+ .map(fromRpcLog)
2691
+ .map((log) => (event ? decodeScanLog(log, event) : log))
2692
+ : await client.getLogs({
2563
2693
  ...(address ? { address } : {}),
2694
+ ...(Object.keys(args).length > 0 ? { args: args as never } : {}),
2564
2695
  event,
2565
2696
  fromBlock,
2566
2697
  strict: false,
2567
2698
  toBlock,
2568
- })
2569
- : await client.getLogs({
2570
- ...(address ? { address } : {}),
2571
- fromBlock,
2572
- toBlock,
2573
2699
  })) as unknown as ScanLog[]
2574
2700
 
2575
2701
  logs.sort((a, b) => {
@@ -2884,7 +3010,7 @@ function toPublic(subscription: Webhooks.Subscription) {
2884
3010
  chainId: subscription.chainId,
2885
3011
  ...(subscription.context === undefined ? {} : { context: subscription.context }),
2886
3012
  createdAt: subscription.createdAt,
2887
- destination: subscription.destination,
3013
+ destination: redactDestination(subscription.destination),
2888
3014
  eventType: subscription.eventType,
2889
3015
  ...(subscription.expiresAt === undefined ? {} : { expiresAt: subscription.expiresAt }),
2890
3016
  failureCount: subscription.failureCount,
@@ -2900,7 +3026,18 @@ function toPublic(subscription: Webhooks.Subscription) {
2900
3026
 
2901
3027
  /** Public read shape plus the one-time signing secret (creation only). */
2902
3028
  function toCreated(subscription: Webhooks.Subscription) {
2903
- return { ...toPublic(subscription), secret: subscription.secret }
3029
+ return {
3030
+ ...toPublic(subscription),
3031
+ destination: subscription.destination,
3032
+ secret: subscription.secret,
3033
+ }
3034
+ }
3035
+
3036
+ /** Redacts bearer credentials from the destination returned by read endpoints. */
3037
+ function redactDestination(destination: WebhookDestination.Destination) {
3038
+ if (destination.type === 'slack') return { ...destination, url: redactedSlackUrl }
3039
+ if (destination.type === 'betterstack') return { ...destination, token: redactedBetterstackToken }
3040
+ return destination
2904
3041
  }
2905
3042
 
2906
3043
  function mutationError(c: Context<App.Environment>, cause: unknown) {
@@ -62,6 +62,7 @@ type StubSubscription = { items?: readonly Billing.MeterKind[]; status: string }
62
62
  function stubStripe(options: {
63
63
  meterEvent?: ((params: { identifier: string }) => void) | undefined
64
64
  subscriptions?: readonly StubSubscription[] | undefined
65
+ subscriptionsList?: (() => Promise<void>) | undefined
65
66
  }) {
66
67
  const meterEvents: {
67
68
  event_name: string
@@ -127,7 +128,10 @@ function stubStripe(options: {
127
128
  subscriptionsCreated.push(params)
128
129
  return {}
129
130
  },
130
- list: async () => ({ data: subscriptions }),
131
+ list: async () => {
132
+ await options.subscriptionsList?.()
133
+ return { data: subscriptions }
134
+ },
131
135
  },
132
136
  }
133
137
  return { itemsCreated, meterEvents, stripe: stripe as never as Stripe, subscriptionsCreated }
@@ -433,6 +437,51 @@ describe('createRequestUsageReporter', () => {
433
437
  await db.close()
434
438
  })
435
439
 
440
+ test('behavior: overlapping ticks cannot claim the same observed watermark twice', async () => {
441
+ const db = create()
442
+ await db.migrate()
443
+ await StripeCustomers.create(db, { orgId: 'org_1', stripeCustomerId: 'cus_1' })
444
+ await StripeCustomers.setStatus(db, 'org_1', 'active')
445
+
446
+ const paused = Promise.withResolvers<void>()
447
+ const resume = Promise.withResolvers<void>()
448
+ const delayed = stubStripe({
449
+ subscriptions: [{ items: ['feePayerSpend', 'apiRequests'], status: 'active' }],
450
+ subscriptionsList: async () => {
451
+ paused.resolve()
452
+ await resume.promise
453
+ },
454
+ })
455
+ const delayedTick = Billing.createRequestUsageReporter({
456
+ db,
457
+ now,
458
+ readCounts: async () => [{ bucketStart, count: 5, orgId: 'org_1' }],
459
+ stripe: delayed.stripe,
460
+ }).tick()
461
+
462
+ // Pause after this tick observes an empty watermark, then let an
463
+ // overlapping tick report and settle that same count first.
464
+ await paused.promise
465
+ const winner = stubStripe({
466
+ subscriptions: [{ items: ['feePayerSpend', 'apiRequests'], status: 'active' }],
467
+ })
468
+ expect(
469
+ await Billing.createRequestUsageReporter({
470
+ db,
471
+ now,
472
+ readCounts: async () => [{ bucketStart, count: 5, orgId: 'org_1' }],
473
+ stripe: winner.stripe,
474
+ }).tick(),
475
+ ).toStrictEqual({ failed: 0, reported: 1, skipped: 0 })
476
+
477
+ resume.resolve()
478
+ expect(await delayedTick).toStrictEqual({ failed: 0, reported: 0, skipped: 0 })
479
+ expect(winner.meterEvents).toHaveLength(1)
480
+ expect(delayed.meterEvents).toHaveLength(0)
481
+ expect((await RequestUsage.getBucket(db, { bucketStart, environment: 'production', orgId: 'org_1' }))?.reportedCount).toBe(5) // prettier-ignore
482
+ await db.close()
483
+ })
484
+
436
485
  test('behavior: seals only hours older than the settlement delay', async () => {
437
486
  const db = create()
438
487
  await db.migrate()
@@ -510,7 +510,8 @@ export function createRequestUsageReporter(
510
510
  subscriptions.set(orgId, subscription)
511
511
  }
512
512
 
513
- const sequence = await RequestUsage.countReported(database, key)
513
+ // The observed watermark is stable for every tick claiming this delta.
514
+ const sequence = watermark?.reportedCount ?? 0
514
515
  const identifier = usageIdentifier(environment, orgId, bucketStart, sequence)
515
516
  const inserted = await RequestUsage.insertPending(database, {
516
517
  ...key,
@@ -599,17 +600,17 @@ function startOfHour(ms: number) {
599
600
 
600
601
  /**
601
602
  * Deterministic meter-event identifier, bounded under Stripe's 100-char limit:
602
- * `rq_${env}_${sha(orgId)[:12]}_${bucketEpochHour}_${sequence}`.
603
+ * `rq_${env}_${sha(orgId)[:12]}_${bucketEpochHour}_${observedWatermark}`.
603
604
  */
604
605
  function usageIdentifier(
605
606
  environment: StripeCustomers.Record['environment'],
606
607
  orgId: string,
607
608
  bucketStart: string,
608
- sequence: number,
609
+ observedWatermark: number,
609
610
  ) {
610
611
  const hash = Hash.keccak256(Hex.fromString(orgId)).slice(2, 14)
611
612
  const epochHour = Math.floor(new Date(bucketStart).getTime() / hourMs)
612
- return `rq_${environment}_${hash}_${epochHour}_${sequence}`
613
+ return `rq_${environment}_${hash}_${epochHour}_${observedWatermark}`
613
614
  }
614
615
 
615
616
  /**
@@ -750,7 +750,12 @@ export function billing() {
750
750
  // the gate never opens (`Billing.status` reads the stored status)
751
751
  // without a subscription to invoice the spend against. A failure
752
752
  // here surfaces as 5xx and Stripe redelivers.
753
- if (status === 'active') await core_Billing.ensureSubscription(client, customer)
753
+ if (status === 'active')
754
+ await core_Billing.ensureSubscription(
755
+ client,
756
+ customer,
757
+ core_Billing.kindsFor(record.environment),
758
+ )
754
759
  await StripeCustomers.setStatus(db, record.orgId, status, record.environment)
755
760
  await syncKeyBilling(c, { environment: record.environment, orgId: record.orgId, status })
756
761
  return c.json({ received: true }, 200)
@@ -298,7 +298,13 @@ export function orgs() {
298
298
  description: 'Delete an organization and all of its projects.',
299
299
  operationId: 'deleteOrganization',
300
300
  responses: OpenApi.responses({
301
- errors: { 404: 'No accessible organization was found for this id.' },
301
+ errors: {
302
+ 404: 'No accessible organization was found for this id.',
303
+ 409: {
304
+ codes: ['sponsorships_unreported'],
305
+ description: 'Billable sponsorships are still awaiting settlement.',
306
+ },
307
+ },
302
308
  success: {
303
309
  description: 'Confirmation that the organization was deleted.',
304
310
  schema: schema.deleteOrganization.Response,
@@ -318,6 +324,8 @@ export function orgs() {
318
324
 
319
325
  const db = Db.get(c.get('db'))
320
326
  try {
327
+ if (await Organizations.hasUnreportedSponsorships(db, Auth.org(c).id))
328
+ return sponsorshipsUnreported(c)
321
329
  const kv = c.get('kv')
322
330
  if (kv)
323
331
  for (const record of await ApiKeys.listByOrg(kv.store, Auth.org(c).id, {
@@ -331,12 +339,22 @@ export function orgs() {
331
339
  200,
332
340
  )
333
341
  } catch (cause) {
342
+ if (cause instanceof Organizations.UnreportedSponsorshipsError)
343
+ return sponsorshipsUnreported(c)
334
344
  return Response.upstream(c, cause)
335
345
  }
336
346
  },
337
347
  )
338
348
  }
339
349
 
350
+ function sponsorshipsUnreported(c: Context<App.Environment>) {
351
+ return Response.error(c, {
352
+ code: 'sponsorships_unreported',
353
+ message: 'Billable sponsorships are still awaiting settlement',
354
+ status: 409,
355
+ })
356
+ }
357
+
340
358
  function serializeOrganization(
341
359
  record: Organizations.Record & { role?: Memberships.Role | undefined },
342
360
  ) {
@@ -10,10 +10,12 @@ import {
10
10
  import { getTransactionReceipt } from 'viem/actions'
11
11
 
12
12
  import * as Db from '../../db/Db.js'
13
+ import * as BillingSettings from '../../db/tables/billingSettings.js'
13
14
  import * as Fees from '../../internal/Fees.js'
14
15
  import * as SponsoredTransactions from '../../db/tables/sponsoredTransactions.js'
15
16
  import * as Tidx from '../../internal/Tidx.js'
16
17
  import * as Viem from '../../internal/Viem.js'
18
+ import * as Billing from '../management/Billing.js'
17
19
 
18
20
  /**
19
21
  * Runs one finalization pass over pending sponsored transactions: rows with a
@@ -177,7 +179,12 @@ export async function reconcile(db: Db.Db, options: reconcile.Options): Promise<
177
179
  )
178
180
  const intent = bySignPayload.get(signPayload)
179
181
  if (!intent) continue
180
- await SponsoredTransactions.assignTransactionHash(db, intent.id, hash)
182
+ await SponsoredTransactions.assignTransactionHash(
183
+ db,
184
+ intent.id,
185
+ hash,
186
+ await options.limitFor?.(intent),
187
+ )
181
188
  bySignPayload.delete(signPayload)
182
189
  result.matched++
183
190
  } catch {
@@ -204,6 +211,12 @@ export declare namespace reconcile {
204
211
  getClient: (chainId: number) => Client
205
212
  /** TIDX client resolver keyed by the intent's chain id. */
206
213
  getTidx: (chainId: number) => Tidx.Client
214
+ /** Resolves the current period spend limit before reopening a failed intent. */
215
+ limitFor?:
216
+ | ((
217
+ intent: SponsoredTransactions.Record,
218
+ ) => Promise<SponsoredTransactions.reserve.Limit | undefined>)
219
+ | undefined
207
220
  /** Lookback slack before the oldest intent's creation. @default 2 minutes */
208
221
  skewMs?: number | undefined
209
222
  }
@@ -270,6 +283,16 @@ export function createFinalizer(options: createFinalizer.Options): createFinaliz
270
283
  feePayer,
271
284
  getClient,
272
285
  getTidx: (chainId) => getTidx(chainId as Viem.ChainId),
286
+ limitFor: async (intent) => {
287
+ if (!intent.billable || !Viem.isMainnet(intent.chainId)) return undefined
288
+ const settings = await BillingSettings.get(db, intent.orgId)
289
+ if (settings?.spendLimit == null) return undefined
290
+ return {
291
+ chainIds: [Viem.chainId.mainnet],
292
+ max: Billing.toBaseUnits(settings.spendLimit),
293
+ since: Billing.periodStart(settings.period),
294
+ }
295
+ },
273
296
  })
274
297
  : { matched: 0, scanned: 0 }
275
298
  const finalized = await finalize(db, {
package/src/db/Db.ts CHANGED
@@ -641,6 +641,10 @@ export const migrations: readonly Migration[] = [
641
641
  .on('inviteLinkRedemptions')
642
642
  .columns(['inviteLinkId', 'createdAt']),
643
643
  },
644
+ {
645
+ name: '0058_request_usage_sequence_bigint',
646
+ up: (db) => raw(db, sql`ALTER TABLE request_usage_meter_events ALTER COLUMN sequence TYPE bigint`), // prettier-ignore
647
+ },
644
648
  ]
645
649
 
646
650
  /**
package/src/db/Schema.ts CHANGED
@@ -272,7 +272,7 @@ export const RequestUsageMeterEvent = z.object({
272
272
  .check(z.describe('When Stripe acknowledged the event (ISO 8601), or null.')),
273
273
  sequence: z
274
274
  .number()
275
- .check(z.describe('Delta sequence within the bucket; `0` is the initial seal, `1..n` later deltas.')), // prettier-ignore
275
+ .check(z.describe('Reported-count watermark observed when this delta was claimed.')),
276
276
  state: z
277
277
  .enum(['pending', 'reported', 'skipped'])
278
278
  .check(z.describe('Exactly-once state: `pending` retries, `reported` is done, `skipped` is audited.')), // prettier-ignore
@@ -4,6 +4,8 @@ import * as Runtime from '../../../test/runtime.js'
4
4
  import * as Webhooks from '../../internal/Webhooks.js'
5
5
  import * as Db from '../Db.js'
6
6
  import * as Organizations from './organizations.js'
7
+ import * as SponsoredTransactions from './sponsoredTransactions.js'
8
+ import * as StripeCustomers from './stripeCustomers.js'
7
9
 
8
10
  const create = () => Db.postgres({ connectionString: Runtime.postgresUrl, schema: `t_${nanoid()}` })
9
11
 
@@ -127,6 +129,34 @@ describe('upsert', () => {
127
129
  })
128
130
 
129
131
  describe('deleteOrganization', () => {
132
+ test('behavior: preserves an org with unreported billable sponsorships', async () => {
133
+ const db = create()
134
+ await db.migrate()
135
+ await Organizations.create(db, { id: 'org_1', name: 'One' })
136
+ await StripeCustomers.create(db, {
137
+ orgId: 'org_1',
138
+ stripeCustomerId: 'cus_org_1',
139
+ })
140
+ await SponsoredTransactions.upsert(db, {
141
+ apiKeyId: 'key_1',
142
+ billable: true,
143
+ chainId: 4217,
144
+ environment: 'production',
145
+ orgId: 'org_1',
146
+ projectId: 'prj_1',
147
+ signPayload: `0x${'bb'.repeat(32)}`,
148
+ transaction: `0x76${'cc'.repeat(16)}`,
149
+ transactionHash: `0x${'aa'.repeat(32)}`,
150
+ })
151
+
152
+ await expect(Organizations.deleteOrganization(db, 'org_1')).rejects.toThrow(
153
+ Organizations.UnreportedSponsorshipsError,
154
+ )
155
+ expect(await Organizations.get(db, 'org_1')).toBeDefined()
156
+ expect(await StripeCustomers.get(db, 'org_1')).toBeDefined()
157
+ await db.close()
158
+ })
159
+
130
160
  test('behavior: deletes api-key-owned webhook subscriptions', async () => {
131
161
  const db = create()
132
162
  await db.migrate()