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.
- package/dist/App.d.ts +34 -18
- package/dist/App.d.ts.map +1 -1
- package/dist/admin/apps/verified-tokens.d.ts +117 -117
- package/dist/apps/data/App.d.ts +3217 -3217
- package/dist/apps/data/FundingQuoteProviders.d.ts +32 -0
- package/dist/apps/data/FundingQuoteProviders.d.ts.map +1 -1
- package/dist/apps/data/FundingQuoteProviders.js +134 -1
- package/dist/apps/data/FundingQuoteProviders.js.map +1 -1
- package/dist/apps/data/routes/addresses.d.ts +206 -206
- package/dist/apps/data/routes/balances.d.ts +46 -46
- package/dist/apps/data/routes/coingecko.d.ts +169 -169
- package/dist/apps/data/routes/funding.d.ts +8 -2
- package/dist/apps/data/routes/funding.d.ts.map +1 -1
- package/dist/apps/data/routes/funding.js +4 -4
- package/dist/apps/data/routes/funding.js.map +1 -1
- package/dist/apps/data/routes/indexer.d.ts +34 -34
- package/dist/apps/data/routes/receipts.d.ts +228 -228
- package/dist/apps/data/routes/rpc.d.ts +31 -31
- package/dist/apps/data/routes/transfers.d.ts +8 -15
- package/dist/apps/data/routes/transfers.d.ts.map +1 -1
- package/dist/apps/data/routes/transfers.js +21 -27
- package/dist/apps/data/routes/transfers.js.map +1 -1
- package/dist/apps/data/routes/webhooks.d.ts +35 -35
- package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
- package/dist/apps/data/routes/webhooks.js +176 -42
- package/dist/apps/data/routes/webhooks.js.map +1 -1
- package/dist/apps/management/App.d.ts +982 -966
- package/dist/apps/management/App.d.ts.map +1 -1
- package/dist/apps/management/Billing.d.ts.map +1 -1
- package/dist/apps/management/Billing.js +5 -4
- package/dist/apps/management/Billing.js.map +1 -1
- package/dist/apps/management/routes/billing.d.ts.map +1 -1
- package/dist/apps/management/routes/billing.js +1 -1
- package/dist/apps/management/routes/billing.js.map +1 -1
- package/dist/apps/management/routes/invitations.d.ts +121 -121
- package/dist/apps/management/routes/invite-links.d.ts +174 -174
- package/dist/apps/management/routes/orgs.d.ts +102 -86
- package/dist/apps/management/routes/orgs.d.ts.map +1 -1
- package/dist/apps/management/routes/orgs.js +18 -1
- package/dist/apps/management/routes/orgs.js.map +1 -1
- package/dist/apps/management/routes/projects.d.ts +124 -124
- package/dist/apps/relay/Sponsorships.d.ts +3 -0
- package/dist/apps/relay/Sponsorships.d.ts.map +1 -1
- package/dist/apps/relay/Sponsorships.js +15 -1
- package/dist/apps/relay/Sponsorships.js.map +1 -1
- package/dist/db/Db.d.ts.map +1 -1
- package/dist/db/Db.js +4 -0
- package/dist/db/Db.js.map +1 -1
- package/dist/db/Schema.js +1 -1
- package/dist/db/Schema.js.map +1 -1
- package/dist/db/tables/organizations.d.ts +6 -0
- package/dist/db/tables/organizations.d.ts.map +1 -1
- package/dist/db/tables/organizations.js +26 -0
- package/dist/db/tables/organizations.js.map +1 -1
- package/dist/db/tables/requestUsage.d.ts +2 -11
- package/dist/db/tables/requestUsage.d.ts.map +1 -1
- package/dist/db/tables/requestUsage.js +1 -20
- package/dist/db/tables/requestUsage.js.map +1 -1
- package/dist/db/tables/sponsoredTransactions.d.ts +2 -1
- package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
- package/dist/db/tables/sponsoredTransactions.js +46 -8
- package/dist/db/tables/sponsoredTransactions.js.map +1 -1
- package/dist/internal/Auth.d.ts +4 -0
- package/dist/internal/Auth.d.ts.map +1 -1
- package/dist/internal/Auth.js +7 -8
- package/dist/internal/Auth.js.map +1 -1
- package/dist/internal/Cache.d.ts.map +1 -1
- package/dist/internal/Cache.js +9 -1
- package/dist/internal/Cache.js.map +1 -1
- package/dist/internal/EdgeCache.d.ts.map +1 -1
- package/dist/internal/EdgeCache.js +12 -3
- package/dist/internal/EdgeCache.js.map +1 -1
- package/dist/internal/FundingQuotes.d.ts +16 -10
- package/dist/internal/FundingQuotes.d.ts.map +1 -1
- package/dist/internal/FundingQuotes.js +9 -0
- package/dist/internal/FundingQuotes.js.map +1 -1
- package/dist/internal/RateLimit.d.ts +2 -0
- package/dist/internal/RateLimit.d.ts.map +1 -1
- package/dist/internal/RateLimit.js +4 -4
- package/dist/internal/RateLimit.js.map +1 -1
- package/package.json +6 -1
- package/src/App.test.ts +2 -2
- package/src/Client.test-d.ts +28 -2
- package/src/apps/data/FundingQuoteProviders.test.ts +184 -0
- package/src/apps/data/FundingQuoteProviders.ts +185 -1
- package/src/apps/data/routes/funding.test.ts +7 -0
- package/src/apps/data/routes/funding.ts +4 -4
- package/src/apps/data/routes/tokens.test.ts +54 -0
- package/src/apps/data/routes/transfers.test.ts +73 -1
- package/src/apps/data/routes/transfers.ts +24 -30
- package/src/apps/data/routes/webhooks.test.ts +203 -0
- package/src/apps/data/routes/webhooks.ts +191 -54
- package/src/apps/management/Billing.test.ts +50 -1
- package/src/apps/management/Billing.ts +5 -4
- package/src/apps/management/routes/billing.ts +6 -1
- package/src/apps/management/routes/orgs.ts +19 -1
- package/src/apps/relay/Sponsorships.ts +24 -1
- package/src/db/Db.ts +4 -0
- package/src/db/Schema.ts +1 -1
- package/src/db/tables/organizations.test.ts +30 -0
- package/src/db/tables/organizations.ts +28 -0
- package/src/db/tables/requestUsage.ts +3 -23
- package/src/db/tables/sponsoredTransactions.test.ts +41 -0
- package/src/db/tables/sponsoredTransactions.ts +46 -7
- package/src/internal/Auth.test.ts +63 -10
- package/src/internal/Auth.ts +11 -9
- package/src/internal/Cache.test.ts +19 -0
- package/src/internal/Cache.ts +13 -1
- package/src/internal/EdgeCache.test.ts +40 -0
- package/src/internal/EdgeCache.ts +11 -3
- package/src/internal/FundingQuotes.test.ts +33 -0
- package/src/internal/FundingQuotes.ts +9 -0
- package/src/internal/RateLimit.test.ts +12 -0
- 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
|
-
.
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
.
|
|
118
|
-
.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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:
|
|
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
|
-
.
|
|
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)
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
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
|
|
2529
|
-
* then re-checks **every** filter in JS
|
|
2530
|
-
* `not`, ranges,
|
|
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):
|
|
2533
|
-
*
|
|
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
|
-
*
|
|
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 =
|
|
2553
|
-
if (
|
|
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
|
|
2562
|
-
|
|
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 {
|
|
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 () =>
|
|
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
|
-
|
|
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}_${
|
|
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
|
-
|
|
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}_${
|
|
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')
|
|
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: {
|
|
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(
|
|
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('
|
|
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()
|