mppx 0.8.13 → 0.8.14

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 (123) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/Errors.d.ts +10 -0
  3. package/dist/Errors.d.ts.map +1 -1
  4. package/dist/Errors.js +11 -2
  5. package/dist/Errors.js.map +1 -1
  6. package/dist/Method.d.ts +105 -3
  7. package/dist/Method.d.ts.map +1 -1
  8. package/dist/Method.js +102 -2
  9. package/dist/Method.js.map +1 -1
  10. package/dist/client/internal/Fetch.d.ts.map +1 -1
  11. package/dist/client/internal/Fetch.js +12 -2
  12. package/dist/client/internal/Fetch.js.map +1 -1
  13. package/dist/client/internal/MethodChallenge.d.ts +17 -0
  14. package/dist/client/internal/MethodChallenge.d.ts.map +1 -0
  15. package/dist/client/internal/MethodChallenge.js +11 -0
  16. package/dist/client/internal/MethodChallenge.js.map +1 -0
  17. package/dist/server/Mppx.d.ts +23 -4
  18. package/dist/server/Mppx.d.ts.map +1 -1
  19. package/dist/server/Mppx.js +91 -82
  20. package/dist/server/Mppx.js.map +1 -1
  21. package/dist/tempo/client/Methods.d.ts +1 -0
  22. package/dist/tempo/client/Methods.d.ts.map +1 -1
  23. package/dist/tempo/internal/fee-payer.d.ts +1 -0
  24. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  25. package/dist/tempo/internal/fee-payer.js +7 -2
  26. package/dist/tempo/internal/fee-payer.js.map +1 -1
  27. package/dist/tempo/legacy/client/ChannelOps.d.ts.map +1 -1
  28. package/dist/tempo/legacy/client/ChannelOps.js +2 -1
  29. package/dist/tempo/legacy/client/ChannelOps.js.map +1 -1
  30. package/dist/tempo/server/Charge.d.ts +38 -77
  31. package/dist/tempo/server/Charge.d.ts.map +1 -1
  32. package/dist/tempo/server/Charge.js +289 -209
  33. package/dist/tempo/server/Charge.js.map +1 -1
  34. package/dist/tempo/server/Methods.d.ts +12 -1
  35. package/dist/tempo/server/Methods.d.ts.map +1 -1
  36. package/dist/tempo/server/Methods.js +4 -0
  37. package/dist/tempo/server/Methods.js.map +1 -1
  38. package/dist/tempo/server/Relay.d.ts +48 -0
  39. package/dist/tempo/server/Relay.d.ts.map +1 -0
  40. package/dist/tempo/server/Relay.js +177 -0
  41. package/dist/tempo/server/Relay.js.map +1 -0
  42. package/dist/tempo/server/SponsorBudget.d.ts +59 -0
  43. package/dist/tempo/server/SponsorBudget.d.ts.map +1 -0
  44. package/dist/tempo/server/SponsorBudget.js +144 -0
  45. package/dist/tempo/server/SponsorBudget.js.map +1 -0
  46. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  47. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  48. package/dist/tempo/server/internal/html.gen.js +1 -1
  49. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  50. package/dist/tempo/session/client/ChannelOps.d.ts +7 -1
  51. package/dist/tempo/session/client/ChannelOps.d.ts.map +1 -1
  52. package/dist/tempo/session/client/ChannelOps.js +9 -4
  53. package/dist/tempo/session/client/ChannelOps.js.map +1 -1
  54. package/dist/tempo/session/client/CredentialState.d.ts +5 -1
  55. package/dist/tempo/session/client/CredentialState.d.ts.map +1 -1
  56. package/dist/tempo/session/client/CredentialState.js +37 -19
  57. package/dist/tempo/session/client/CredentialState.js.map +1 -1
  58. package/dist/tempo/session/client/Runtime.d.ts +8 -0
  59. package/dist/tempo/session/client/Runtime.d.ts.map +1 -1
  60. package/dist/tempo/session/client/Runtime.js +17 -0
  61. package/dist/tempo/session/client/Runtime.js.map +1 -1
  62. package/dist/tempo/session/client/Session.d.ts +15 -5
  63. package/dist/tempo/session/client/Session.d.ts.map +1 -1
  64. package/dist/tempo/session/client/Session.js +93 -36
  65. package/dist/tempo/session/client/Session.js.map +1 -1
  66. package/dist/tempo/session/client/SessionManager.d.ts +8 -1
  67. package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
  68. package/dist/tempo/session/client/SessionManager.js +54 -26
  69. package/dist/tempo/session/client/SessionManager.js.map +1 -1
  70. package/dist/tempo/session/client/Transports.d.ts +7 -5
  71. package/dist/tempo/session/client/Transports.d.ts.map +1 -1
  72. package/dist/tempo/session/client/Transports.js +48 -24
  73. package/dist/tempo/session/client/Transports.js.map +1 -1
  74. package/dist/tempo/session/precompile/Chain.d.ts.map +1 -1
  75. package/dist/tempo/session/precompile/Chain.js +96 -8
  76. package/dist/tempo/session/precompile/Chain.js.map +1 -1
  77. package/dist/tempo/session/precompile/Channel.d.ts +2 -0
  78. package/dist/tempo/session/precompile/Channel.d.ts.map +1 -1
  79. package/dist/tempo/session/precompile/Channel.js +4 -0
  80. package/dist/tempo/session/precompile/Channel.js.map +1 -1
  81. package/dist/tempo/session/server/RequestState.js +3 -1
  82. package/dist/tempo/session/server/RequestState.js.map +1 -1
  83. package/package.json +1 -1
  84. package/src/Errors.test.ts +23 -0
  85. package/src/Errors.ts +21 -2
  86. package/src/Method.test.ts +102 -1
  87. package/src/Method.ts +241 -5
  88. package/src/client/Mppx.test-d.ts +3 -3
  89. package/src/client/internal/Fetch.ts +16 -7
  90. package/src/client/internal/MethodChallenge.ts +30 -0
  91. package/src/server/Mppx.test-d.ts +2 -0
  92. package/src/server/Mppx.test.ts +205 -0
  93. package/src/server/Mppx.ts +167 -103
  94. package/src/tempo/PublicExports.test-d.ts +20 -0
  95. package/src/tempo/internal/fee-payer.ts +7 -3
  96. package/src/tempo/legacy/client/ChannelOps.test.ts +7 -0
  97. package/src/tempo/legacy/client/ChannelOps.ts +2 -1
  98. package/src/tempo/server/Charge.test.ts +337 -63
  99. package/src/tempo/server/Charge.ts +435 -267
  100. package/src/tempo/server/Methods.ts +11 -0
  101. package/src/tempo/server/Relay.test.ts +523 -0
  102. package/src/tempo/server/Relay.ts +288 -0
  103. package/src/tempo/server/SponsorBudget.test.ts +125 -0
  104. package/src/tempo/server/SponsorBudget.ts +213 -0
  105. package/src/tempo/server/internal/html.gen.ts +1 -1
  106. package/src/tempo/session/client/ChannelOps.test.ts +17 -2
  107. package/src/tempo/session/client/ChannelOps.ts +12 -4
  108. package/src/tempo/session/client/CredentialState.ts +61 -20
  109. package/src/tempo/session/client/Runtime.test.ts +12 -0
  110. package/src/tempo/session/client/Runtime.ts +22 -0
  111. package/src/tempo/session/client/Session.test.ts +235 -2
  112. package/src/tempo/session/client/Session.ts +130 -41
  113. package/src/tempo/session/client/SessionManager.test.ts +102 -4
  114. package/src/tempo/session/client/SessionManager.ts +71 -27
  115. package/src/tempo/session/client/Transports.test.ts +32 -1
  116. package/src/tempo/session/client/Transports.ts +79 -35
  117. package/src/tempo/session/precompile/Chain.integration.test.ts +19 -1
  118. package/src/tempo/session/precompile/Chain.test.ts +197 -10
  119. package/src/tempo/session/precompile/Chain.ts +102 -8
  120. package/src/tempo/session/precompile/Channel.test.ts +9 -0
  121. package/src/tempo/session/precompile/Channel.ts +5 -0
  122. package/src/tempo/session/server/RequestState.test.ts +8 -1
  123. package/src/tempo/session/server/RequestState.ts +2 -2
@@ -0,0 +1,288 @@
1
+ import { Bytes, Hash, Hex, Json } from 'ox'
2
+
3
+ import { PaymentExpiredError, VerificationFailedError } from '../../Errors.js'
4
+ import type * as Method from '../../Method.js'
5
+ import * as Receipt from '../../Receipt.js'
6
+
7
+ const defaultApiBaseUrl = 'https://api.tempo.xyz'
8
+
9
+ const relayErrorCode = [
10
+ 'already_used',
11
+ 'broadcast_failed',
12
+ 'expired',
13
+ 'invalid_payment',
14
+ 'insufficient_funds',
15
+ 'policy_denied',
16
+ 'screen_rejected',
17
+ 'simulation_failed',
18
+ 'temporarily_unavailable',
19
+ 'unsupported',
20
+ 'unknown',
21
+ ] as const
22
+
23
+ type RelayErrorCode = (typeof relayErrorCode)[number]
24
+
25
+ /** Error body returned by Tempo API's MPP relay. */
26
+ type RelayError = {
27
+ /** Stable machine-readable reason the relay rejected the credential. */
28
+ code: RelayErrorCode
29
+ /** Human-readable explanation of the relay result. */
30
+ message?: string | undefined
31
+ }
32
+
33
+ /** Credential fields accepted by Tempo API's MPP relay. */
34
+ type RelayInput = {
35
+ /** Challenge from the submitted credential. */
36
+ challenge: Record<string, unknown>
37
+ /** Method-specific credential payload. */
38
+ payload: unknown
39
+ /** Optional payer identity. */
40
+ source?: string | undefined
41
+ }
42
+
43
+ /** Response returned by the MPP relay validation endpoint. */
44
+ type ValidateResponse = { success: true } | { error: RelayError; success: false }
45
+
46
+ /** Receipt returned by the MPP relay after broadcast. */
47
+ type RelayReceipt = {
48
+ /** Optional caller-provided payment reference. */
49
+ externalId?: string | undefined
50
+ /** Payment method that settled the credential. */
51
+ method: string
52
+ /** On-chain or payment-system settlement reference. */
53
+ reference: string
54
+ /** RFC 3339 settlement timestamp. */
55
+ timestamp: string
56
+ }
57
+
58
+ /** Response returned by the MPP relay broadcast endpoint. */
59
+ type BroadcastResponse =
60
+ | { receipt: RelayReceipt; success: true }
61
+ | { error: RelayError; success: false }
62
+
63
+ /**
64
+ * Configures a Tempo payment method to use Tempo API's MPP relay.
65
+ *
66
+ * The adapter preserves the supplied method's challenge configuration while
67
+ * delegating credential validation and terminal broadcast to
68
+ * `/v1/mpp/validate` and `/v1/mpp/broadcast` respectively.
69
+ *
70
+ * @internal
71
+ */
72
+ export function configure<const intent extends Method.Method>(
73
+ method: Method.Server<intent>,
74
+ options: configure.Options,
75
+ ): configure.Adapter<intent> {
76
+ const request = createRequest(options)
77
+
78
+ const validate: Method.ValidateFn<intent> = async (parameters) => {
79
+ const input = toRelayInput(parameters.credential)
80
+ await request.validate(input)
81
+
82
+ return {
83
+ challenge: parameters.credential.challenge,
84
+ credential: parameters.credential,
85
+ details: {},
86
+ intent: method.intent,
87
+ method: method.name,
88
+ request: parameters.credential.challenge.request,
89
+ ...(parameters.credential.source ? { source: parameters.credential.source } : {}),
90
+ } as Method.Validation<intent>
91
+ }
92
+
93
+ const broadcast: Method.BroadcastFn<intent> = async (parameters) => {
94
+ const input = toRelayInput(parameters.credential)
95
+ const receipt = await request.broadcast(input, {
96
+ idempotencyKey: idempotencyKey(input),
97
+ })
98
+ if (receipt.method !== method.name) throw failure()
99
+ try {
100
+ return Receipt.from({ ...receipt, status: 'success' })
101
+ } catch {
102
+ throw failure()
103
+ }
104
+ }
105
+
106
+ // Preserve the legacy combined hook for direct method consumers.
107
+ const verify: Method.VerifyFn<intent> = async (parameters) => {
108
+ await validate(parameters)
109
+ return broadcast(parameters)
110
+ }
111
+
112
+ return {
113
+ ...method,
114
+ broadcast,
115
+ verify,
116
+ validate,
117
+ } as configure.Adapter<intent>
118
+ }
119
+
120
+ export declare namespace configure {
121
+ /**
122
+ * Server method augmented with Tempo API validation and broadcast hooks.
123
+ *
124
+ * The legacy `verify` method validates and broadcasts in one call.
125
+ */
126
+ type Adapter<intent extends Method.Method> = Omit<
127
+ Method.Server<intent>,
128
+ 'broadcast' | 'validate'
129
+ > & {
130
+ /** Broadcasts the credential through Tempo API. */
131
+ broadcast: Method.BroadcastFn<intent>
132
+ /** Validates the credential through Tempo API. */
133
+ validate: Method.ValidateFn<intent>
134
+ }
135
+
136
+ /** Tempo API relay configuration for server-side Tempo charges. */
137
+ type Options = {
138
+ /** Tempo API key with the `mpp:write` scope. */
139
+ apiKey: string
140
+ /** Fetch implementation used to call Tempo API. */
141
+ fetch?: typeof globalThis.fetch | undefined
142
+ /** Tempo API base URL, including an optional path prefix. @default 'https://api.tempo.xyz' */
143
+ apiBaseUrl?: string | undefined
144
+ }
145
+
146
+ /** Stable failure codes returned by Tempo API's MPP relay. */
147
+ type ErrorCode = RelayErrorCode
148
+
149
+ /** Safe relay error details exposed in Payment Auth problem details. */
150
+ type ErrorDetails =
151
+ | { code: 'already_used' | 'broadcast_failed' | 'insufficient_funds' | 'invalid_payment' }
152
+ | { code: 'simulation_failed' | 'unsupported' }
153
+ | { code: 'temporarily_unavailable'; retry: 'same_credential' }
154
+ }
155
+
156
+ function createRequest(options: configure.Options) {
157
+ const fetch = options.fetch ?? globalThis.fetch
158
+ const apiBaseUrl = new URL(options.apiBaseUrl ?? defaultApiBaseUrl)
159
+ if (!apiBaseUrl.pathname.endsWith('/')) apiBaseUrl.pathname += '/'
160
+
161
+ async function post(
162
+ path: 'v1/mpp/broadcast' | 'v1/mpp/validate',
163
+ input: RelayInput,
164
+ headers?: Record<string, string>,
165
+ ): Promise<unknown> {
166
+ let response: Response
167
+ try {
168
+ response = await fetch(new URL(path, apiBaseUrl), {
169
+ body: JSON.stringify(input),
170
+ headers: {
171
+ Accept: 'application/json',
172
+ 'content-type': 'application/json',
173
+ 'tempo-api-key': options.apiKey,
174
+ ...headers,
175
+ },
176
+ method: 'POST',
177
+ })
178
+ } catch {
179
+ throw failure()
180
+ }
181
+
182
+ if (!response.ok) throw failure()
183
+ return response.json().catch(() => undefined)
184
+ }
185
+
186
+ const validate = async (input: RelayInput) => {
187
+ const response = await post('v1/mpp/validate', input)
188
+ if (!isValidateSuccess(response)) throw failure(response)
189
+ }
190
+
191
+ const broadcast = async (input: RelayInput, broadcastOptions: { idempotencyKey: string }) => {
192
+ const response = await post('v1/mpp/broadcast', input, {
193
+ 'idempotency-key': broadcastOptions.idempotencyKey,
194
+ })
195
+ if (!isBroadcastSuccess(response)) throw failure(response)
196
+ return response.receipt
197
+ }
198
+
199
+ return {
200
+ broadcast,
201
+ validate,
202
+ }
203
+ }
204
+
205
+ function toRelayInput(credential: {
206
+ challenge: Record<string, unknown>
207
+ payload: unknown
208
+ source?: string | undefined
209
+ }): RelayInput {
210
+ return {
211
+ challenge: credential.challenge,
212
+ payload: credential.payload,
213
+ ...(credential.source ? { source: credential.source } : {}),
214
+ }
215
+ }
216
+
217
+ function idempotencyKey(input: RelayInput): string {
218
+ const payload = input.payload
219
+ if (
220
+ isRecord(payload) &&
221
+ payload.type === 'transaction' &&
222
+ typeof payload.signature === 'string' &&
223
+ Hex.validate(payload.signature)
224
+ ) {
225
+ const transactionHash = Hash.keccak256(Hex.toBytes(payload.signature), { as: 'Hex' })
226
+ return `mppx_${transactionHash}`
227
+ }
228
+
229
+ const hash = Hash.sha256(Bytes.fromString(Json.canonicalize(input)), { as: 'Hex' })
230
+ return `mppx_${hash}`
231
+ }
232
+
233
+ function failure(value?: unknown) {
234
+ const code = relayErrorCodeFrom(value)
235
+ if (code === 'expired') return new PaymentExpiredError()
236
+
237
+ const details = code && safeDetails(code)
238
+ return new VerificationFailedError(details ? { details } : undefined)
239
+ }
240
+
241
+ function isValidateSuccess(value: unknown): value is Extract<ValidateResponse, { success: true }> {
242
+ return isRecord(value) && value.success === true
243
+ }
244
+
245
+ function isBroadcastSuccess(
246
+ value: unknown,
247
+ ): value is Extract<BroadcastResponse, { success: true }> {
248
+ return isRecord(value) && value.success === true && isRelayReceipt(value.receipt)
249
+ }
250
+
251
+ function relayErrorCodeFrom(value: unknown): RelayErrorCode | undefined {
252
+ if (!isRecord(value) || !isRecord(value.error) || !isRelayErrorCode(value.error.code)) return
253
+ return value.error.code
254
+ }
255
+
256
+ function isRelayErrorCode(value: unknown): value is RelayErrorCode {
257
+ return typeof value === 'string' && (relayErrorCode as readonly string[]).includes(value)
258
+ }
259
+
260
+ function safeDetails(code: RelayErrorCode): configure.ErrorDetails | undefined {
261
+ switch (code) {
262
+ case 'already_used':
263
+ case 'broadcast_failed':
264
+ case 'insufficient_funds':
265
+ case 'invalid_payment':
266
+ case 'simulation_failed':
267
+ case 'unsupported':
268
+ return { code }
269
+ case 'temporarily_unavailable':
270
+ return { code, retry: 'same_credential' }
271
+ default:
272
+ return
273
+ }
274
+ }
275
+
276
+ function isRelayReceipt(value: unknown): value is RelayReceipt {
277
+ return (
278
+ isRecord(value) &&
279
+ typeof value.method === 'string' &&
280
+ typeof value.reference === 'string' &&
281
+ typeof value.timestamp === 'string' &&
282
+ (value.externalId === undefined || typeof value.externalId === 'string')
283
+ )
284
+ }
285
+
286
+ function isRecord(value: unknown): value is Record<string, unknown> {
287
+ return typeof value === 'object' && value !== null
288
+ }
@@ -0,0 +1,125 @@
1
+ import type { Hex } from 'viem'
2
+ import { describe, expect, test } from 'vp/test'
3
+
4
+ import * as Store from '../../Store.js'
5
+ import * as SponsorBudget from './SponsorBudget.js'
6
+
7
+ const sponsor = '0x0000000000000000000000000000000000000001'
8
+ const hash1 = `0x${'01'.repeat(32)}` as Hex
9
+ const hash2 = `0x${'02'.repeat(32)}` as Hex
10
+ const storeKey = `mppx:charge:sponsor-budget:42431:${sponsor}` as const
11
+
12
+ function memoryStore() {
13
+ return Store.memory() as Parameters<typeof SponsorBudget.reserve>[0]
14
+ }
15
+
16
+ function parameters(overrides: Partial<Parameters<typeof SponsorBudget.reserve>[1]> = {}) {
17
+ return {
18
+ chainId: 42431,
19
+ expiresAt: Date.now() + 10_000,
20
+ fee: 1n,
21
+ getReceipt: async () => {
22
+ throw new Error('not found')
23
+ },
24
+ id: hash1,
25
+ maxReservations: 10,
26
+ maxTotalFee: 1n,
27
+ owner: 'worker-1',
28
+ sponsor,
29
+ transactionHash: hash1,
30
+ waitUntil: Date.now() + 10_000,
31
+ ...overrides,
32
+ } satisfies Parameters<typeof SponsorBudget.reserve>[1]
33
+ }
34
+
35
+ describe('SponsorBudget', () => {
36
+ test('rejects a transaction larger than the aggregate budget', async () => {
37
+ const store = memoryStore()
38
+ await expect(
39
+ SponsorBudget.reserve(store, parameters({ fee: 2n, maxTotalFee: 1n })),
40
+ ).rejects.toThrow('fee exceeds the aggregate sponsor budget')
41
+ expect(await store.get(storeKey)).toBeNull()
42
+ })
43
+
44
+ test('retains pending exposure until a receipt is observed', async () => {
45
+ const store = memoryStore()
46
+ let confirmed = false
47
+ const getReceipt = async (hash: Hex) => {
48
+ if (hash === hash1 && confirmed) return {}
49
+ throw new Error('not found')
50
+ }
51
+ const first = await SponsorBudget.reserve(store, parameters({ getReceipt }))
52
+ expect(await SponsorBudget.transition(store, first, 'broadcasting')).toBe(true)
53
+ expect(await SponsorBudget.transition(store, first, 'pending')).toBe(true)
54
+
55
+ const second = SponsorBudget.reserve(
56
+ store,
57
+ parameters({
58
+ getReceipt,
59
+ id: hash2,
60
+ owner: 'worker-2',
61
+ transactionHash: hash2,
62
+ }),
63
+ )
64
+ expect(
65
+ await Promise.race([
66
+ second.then(() => 'reserved' as const),
67
+ new Promise<'waiting'>((resolve) => setTimeout(() => resolve('waiting'), 40)),
68
+ ]),
69
+ ).toBe('waiting')
70
+
71
+ confirmed = true
72
+ await expect(second).resolves.toMatchObject({ id: hash2, owner: 'worker-2' })
73
+ const state = await store.get(storeKey)
74
+ expect(Object.keys(state!.reservations)).toEqual([hash2])
75
+ })
76
+
77
+ test('fences release and transition by reservation owner', async () => {
78
+ const store = memoryStore()
79
+ const handle = await SponsorBudget.reserve(store, parameters())
80
+ const staleHandle = { ...handle, owner: 'stale-worker' }
81
+
82
+ expect(await SponsorBudget.transition(store, staleHandle, 'broadcasting')).toBe(false)
83
+ expect(await SponsorBudget.release(store, staleHandle)).toBe(false)
84
+ expect(await store.get(storeKey)).toMatchObject({
85
+ reservations: {
86
+ [hash1]: {
87
+ owner: 'worker-1',
88
+ phase: 'prepared',
89
+ transactionHash: hash1,
90
+ },
91
+ },
92
+ })
93
+
94
+ expect(await SponsorBudget.release(store, handle)).toBe(true)
95
+ expect(await store.get(storeKey)).toBeNull()
96
+ })
97
+
98
+ test('caps reservation count independently of fee exposure', async () => {
99
+ const store = memoryStore()
100
+ const first = await SponsorBudget.reserve(
101
+ store,
102
+ parameters({ fee: 0n, maxReservations: 1, maxTotalFee: 100n }),
103
+ )
104
+ const second = SponsorBudget.reserve(
105
+ store,
106
+ parameters({
107
+ fee: 0n,
108
+ id: hash2,
109
+ maxReservations: 1,
110
+ maxTotalFee: 100n,
111
+ owner: 'worker-2',
112
+ transactionHash: hash2,
113
+ }),
114
+ )
115
+
116
+ expect(
117
+ await Promise.race([
118
+ second.then(() => 'reserved' as const),
119
+ new Promise<'waiting'>((resolve) => setTimeout(() => resolve('waiting'), 40)),
120
+ ]),
121
+ ).toBe('waiting')
122
+ await SponsorBudget.release(store, first)
123
+ await expect(second).resolves.toMatchObject({ id: hash2 })
124
+ })
125
+ })
@@ -0,0 +1,213 @@
1
+ import type { Hex } from 'viem'
2
+
3
+ import { VerificationFailedError } from '../../Errors.js'
4
+ import type * as Store from '../../Store.js'
5
+
6
+ export type Phase = 'prepared' | 'broadcasting' | 'pending'
7
+
8
+ export type Reservation = {
9
+ expiresAt: number
10
+ fee: string
11
+ leaseUntil: number
12
+ owner: string
13
+ phase: Phase
14
+ transactionHash: Hex
15
+ }
16
+
17
+ export type State = {
18
+ reservations: Record<string, Reservation>
19
+ version: 1
20
+ }
21
+
22
+ export type Handle = {
23
+ chainId: number
24
+ id: string
25
+ owner: string
26
+ sponsor: Hex
27
+ }
28
+
29
+ type ItemMap = {
30
+ [key: `mppx:charge:sponsor-budget:${string}`]: State
31
+ }
32
+
33
+ type ReserveParameters = Handle & {
34
+ expiresAt: number
35
+ fee: bigint
36
+ getReceipt: (hash: Hex) => Promise<unknown>
37
+ maxReservations: number
38
+ maxTotalFee: bigint
39
+ transactionHash: Hex
40
+ waitUntil: number
41
+ }
42
+
43
+ const initialPollIntervalMs = 10
44
+ const maxPollIntervalMs = 250
45
+ const preparedLeaseMs = 30_000
46
+
47
+ function key(parameters: Pick<Handle, 'chainId' | 'sponsor'>) {
48
+ return `mppx:charge:sponsor-budget:${parameters.chainId}:${parameters.sponsor.toLowerCase()}` as const
49
+ }
50
+
51
+ function isState(value: State | null): value is State {
52
+ return value?.version === 1 && typeof value.reservations === 'object'
53
+ }
54
+
55
+ async function mutateOwned(
56
+ store: Store.AtomicStore<ItemMap>,
57
+ handle: Handle,
58
+ mutate: (reservation: Reservation) => Reservation | null,
59
+ ) {
60
+ return store.update(key(handle), (current) => {
61
+ if (!isState(current)) return { op: 'noop', result: false }
62
+ const reservation = current.reservations[handle.id]
63
+ if (!reservation || reservation.owner !== handle.owner) return { op: 'noop', result: false }
64
+
65
+ const reservations = { ...current.reservations }
66
+ const next = mutate(reservation)
67
+ if (next) reservations[handle.id] = next
68
+ else delete reservations[handle.id]
69
+
70
+ if (Object.keys(reservations).length === 0) return { op: 'delete', result: true }
71
+ return {
72
+ op: 'set',
73
+ value: { reservations, version: 1 },
74
+ result: true,
75
+ }
76
+ })
77
+ }
78
+
79
+ async function reconcile(
80
+ store: Store.AtomicStore<ItemMap>,
81
+ parameters: Pick<ReserveParameters, 'chainId' | 'getReceipt' | 'sponsor'>,
82
+ ) {
83
+ const state = await store.get(key(parameters))
84
+ if (!isState(state)) return
85
+
86
+ const now = Date.now()
87
+ await Promise.all(
88
+ Object.entries(state.reservations).map(async ([id, reservation]) => {
89
+ const handle = {
90
+ chainId: parameters.chainId,
91
+ id,
92
+ owner: reservation.owner,
93
+ sponsor: parameters.sponsor,
94
+ }
95
+ if (
96
+ reservation.expiresAt <= now ||
97
+ (reservation.phase === 'prepared' && reservation.leaseUntil <= now)
98
+ ) {
99
+ await release(store, handle)
100
+ return
101
+ }
102
+ if (reservation.phase === 'prepared') return
103
+
104
+ try {
105
+ await parameters.getReceipt(reservation.transactionHash)
106
+ } catch {
107
+ return
108
+ }
109
+ await release(store, handle)
110
+ }),
111
+ )
112
+ }
113
+
114
+ /**
115
+ * Reserves aggregate sponsor fee capacity across processes.
116
+ *
117
+ * Pending broadcasts remain charged to the budget until a receipt is observed
118
+ * or their expiring nonce becomes invalid. Capacity waiters do not rewrite the
119
+ * shared state while waiting.
120
+ *
121
+ * @internal
122
+ */
123
+ export async function reserve(
124
+ store: Store.AtomicStore<ItemMap>,
125
+ parameters: ReserveParameters,
126
+ ): Promise<Handle> {
127
+ if (parameters.fee > parameters.maxTotalFee)
128
+ throw new VerificationFailedError({
129
+ reason: 'Sponsored transaction fee exceeds the aggregate sponsor budget',
130
+ })
131
+
132
+ let pollIntervalMs = initialPollIntervalMs
133
+ for (;;) {
134
+ const now = Date.now()
135
+ if (now >= parameters.waitUntil)
136
+ throw new VerificationFailedError({
137
+ reason: 'Sponsored transaction expired while waiting for sponsor budget',
138
+ })
139
+
140
+ await reconcile(store, parameters)
141
+ const result = await store.update(key(parameters), (current) => {
142
+ if (current !== null && !isState(current)) return { op: 'noop', result: 'invalid' as const }
143
+
144
+ const reservations = { ...(current?.reservations ?? {}) }
145
+ const existing = reservations[parameters.id]
146
+ if (existing) return { op: 'noop', result: 'duplicate' as const }
147
+
148
+ const values = Object.values(reservations)
149
+ const totalFee = values.reduce((total, reservation) => total + BigInt(reservation.fee), 0n)
150
+ if (
151
+ values.length >= parameters.maxReservations ||
152
+ totalFee + parameters.fee > parameters.maxTotalFee
153
+ )
154
+ return { op: 'noop', result: 'wait' as const }
155
+
156
+ reservations[parameters.id] = {
157
+ expiresAt: parameters.expiresAt,
158
+ fee: parameters.fee.toString(),
159
+ leaseUntil: Math.min(parameters.expiresAt, now + preparedLeaseMs),
160
+ owner: parameters.owner,
161
+ phase: 'prepared',
162
+ transactionHash: parameters.transactionHash,
163
+ }
164
+ return {
165
+ op: 'set',
166
+ value: { reservations, version: 1 },
167
+ result: 'reserved' as const,
168
+ }
169
+ })
170
+
171
+ if (result === 'reserved') return parameters
172
+ if (result === 'invalid')
173
+ throw new VerificationFailedError({
174
+ reason: 'Sponsor budget store contains incompatible state',
175
+ })
176
+ if (result === 'duplicate')
177
+ throw new VerificationFailedError({
178
+ reason: 'Sponsored transaction already has a budget reservation',
179
+ })
180
+
181
+ await new Promise((resolve) =>
182
+ setTimeout(resolve, Math.min(pollIntervalMs, parameters.waitUntil - now)),
183
+ )
184
+ pollIntervalMs = Math.min(pollIntervalMs * 2, maxPollIntervalMs)
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Advances a reservation before and after the broadcast call.
190
+ *
191
+ * The owner token fences stale workers from mutating a replacement reservation.
192
+ *
193
+ * @internal
194
+ */
195
+ export async function transition(
196
+ store: Store.AtomicStore<ItemMap>,
197
+ handle: Handle,
198
+ phase: Exclude<Phase, 'prepared'>,
199
+ ): Promise<boolean> {
200
+ return mutateOwned(store, handle, (reservation) => ({
201
+ ...reservation,
202
+ phase,
203
+ }))
204
+ }
205
+
206
+ /**
207
+ * Releases a reservation only when the caller still owns it.
208
+ *
209
+ * @internal
210
+ */
211
+ export async function release(store: Store.AtomicStore<ItemMap>, handle: Handle): Promise<boolean> {
212
+ return mutateOwned(store, handle, () => null)
213
+ }