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
@@ -37,6 +37,8 @@ import * as Selectors from '../internal/selectors.js'
37
37
  import type * as types from '../internal/types.js'
38
38
  import * as Methods from '../Methods.js'
39
39
  import { html as htmlContent } from './internal/html.gen.js'
40
+ import * as Relay from './Relay.js'
41
+ import * as SponsorBudget from './SponsorBudget.js'
40
42
 
41
43
  /**
42
44
  * Creates a Tempo charge method intent for usage on the server.
@@ -53,7 +55,7 @@ export function charge<const parameters extends charge.Parameters>(
53
55
  parameters,
54
56
  charge.Parameters
55
57
  >,
56
- ) {
58
+ ): Method.Server<typeof Methods.charge, charge.DeriveDefaults<parameters>> {
57
59
  const {
58
60
  amount,
59
61
  currency = defaults.resolveCurrency(parameters),
@@ -64,16 +66,30 @@ export function charge<const parameters extends charge.Parameters>(
64
66
  feePayerPolicy,
65
67
  html,
66
68
  memo,
69
+ relay,
67
70
  splits,
68
71
  supportedModes,
69
72
  validateSender,
70
73
  waitForConfirmation = true,
71
74
  } = parameters
72
75
  const storeKeyPrefix = parameters.storeKeyPrefix ?? ''
73
- const store = Store.from(
74
- (parameters.store ?? Store.memory()) as Store.AtomicStore<charge.StoreItemMap>,
75
- { keyPrefix: storeKeyPrefix },
76
- )
76
+ const rawStore = (parameters.store ?? Store.memory()) as Store.AtomicStore<charge.StoreItemMap>
77
+ const store = Store.from(rawStore, { keyPrefix: storeKeyPrefix })
78
+ // Aggregate exposure belongs to the actual sponsor, not a caller-specific
79
+ // replay namespace. Sharing the raw store gives every tenant/process the same
80
+ // atomic sponsor-wide budget.
81
+ const sponsorBudgetStore = Store.from(rawStore) as Store.AtomicStore<{
82
+ [key: `mppx:charge:sponsor-budget:${string}`]: SponsorBudget.State
83
+ }>
84
+ const {
85
+ maxInFlightReservations = 100,
86
+ maxInFlightTotalFee = (feePayerPolicy?.maxTotalFee ?? 50_000_000_000_000_000n) * 10n,
87
+ ...transactionFeePayerPolicy
88
+ } = feePayerPolicy ?? {}
89
+ if (!Number.isSafeInteger(maxInFlightReservations) || maxInFlightReservations <= 0)
90
+ throw new Error('`feePayerPolicy.maxInFlightReservations` must be a positive safe integer.')
91
+ if (maxInFlightTotalFee <= 0n)
92
+ throw new Error('`feePayerPolicy.maxInFlightTotalFee` must be greater than zero.')
77
93
  const proofStore = parameters.store
78
94
  ? Store.from(parameters.store as Store.AtomicStore<charge.StoreItemMap>, {
79
95
  keyPrefix: storeKeyPrefix,
@@ -91,8 +107,259 @@ export function charge<const parameters extends charge.Parameters>(
91
107
  rpcUrl: defaults.rpcUrl,
92
108
  })
93
109
 
110
+ function resolveRequest(request: Method.VerifyContext<typeof Methods.charge>['request']) {
111
+ const parsed = Methods.charge.schema.request.safeParse(request)
112
+ if (parsed.success) return parsed.data
113
+ // Credential handlers receive the HMAC-bound request in canonical output
114
+ // form, so it must not be transformed a second time.
115
+ return request as unknown as z.output<typeof Methods.charge.schema.request>
116
+ }
117
+
118
+ async function resolveCredentialContext({
119
+ credential,
120
+ request,
121
+ }: {
122
+ credential: Method.VerifyContext<typeof Methods.charge>['credential']
123
+ request: Method.VerifyContext<typeof Methods.charge>['request']
124
+ }) {
125
+ const { challenge, payload } = credential
126
+ const resolvedRequest = resolveRequest(request)
127
+ const chainId = resolvedRequest.methodDetails?.chainId ?? request.chainId
128
+ const { amount, methodDetails } = resolvedRequest
129
+ const supportedModes = methodDetails?.supportedModes as
130
+ | readonly Methods.ChargeMode[]
131
+ | undefined
132
+ const currency = resolvedRequest.currency as `0x${string}`
133
+ const recipient = resolvedRequest.recipient as `0x${string}`
134
+ const memo = methodDetails?.memo as `0x${string}` | undefined
135
+ const isZeroAmount = BigInt(amount) === 0n
136
+
137
+ Expires.assert(challenge.expires, challenge.id)
138
+
139
+ if (isZeroAmount && payload.type !== 'proof')
140
+ throw new MismatchError('Zero-amount challenges require a proof credential.', {})
141
+
142
+ return {
143
+ amount,
144
+ chainId,
145
+ challenge,
146
+ client: await getClient({ chainId }),
147
+ currency,
148
+ isZeroAmount,
149
+ memo,
150
+ methodDetails,
151
+ payload,
152
+ recipient,
153
+ requestAllowsFeePayer: request.feePayer !== false,
154
+ resolvedRequest,
155
+ supportedModes,
156
+ }
157
+ }
158
+
159
+ type CredentialContext = Awaited<ReturnType<typeof resolveCredentialContext>>
160
+ type Credential = Method.VerifyContext<typeof Methods.charge>['credential']
161
+ type HashPayload = Extract<Credential['payload'], { type: 'hash' }>
162
+ type ProofPayload = Extract<Credential['payload'], { type: 'proof' }>
163
+ type TransactionPayload = Extract<Credential['payload'], { type: 'transaction' }>
164
+
165
+ async function validateHashCredential(
166
+ credential: Credential,
167
+ payload: HashPayload,
168
+ context: CredentialContext,
169
+ ) {
170
+ const {
171
+ amount,
172
+ chainId,
173
+ challenge,
174
+ client,
175
+ currency,
176
+ memo,
177
+ methodDetails,
178
+ recipient,
179
+ supportedModes,
180
+ } = context
181
+ if (supportedModes && !supportedModes.includes('push'))
182
+ throw new MismatchError('Hash credentials are not supported for this challenge.', {})
183
+
184
+ const source = parseHashCredentialSource({
185
+ chainId: chainId ?? client.chain?.id,
186
+ source: credential.source,
187
+ })
188
+ const transfers = getExpectedTransfers({ amount, memo, methodDetails, recipient })
189
+ const receipt = await getTransactionReceipt(client, {
190
+ hash: payload.hash as `0x${string}`,
191
+ })
192
+ const sender = source?.address ?? receipt.from
193
+ const matchedLogs = await assertTransferLogs(receipt, {
194
+ currency,
195
+ sender,
196
+ source,
197
+ transfers,
198
+ validateSender,
199
+ })
200
+ if (!memo)
201
+ assertChallengeBoundMemo(matchedLogs, {
202
+ challengeId: challenge.id,
203
+ realm: challenge.realm,
204
+ })
205
+ return { receipt: toReceipt(receipt), sender, transfers }
206
+ }
207
+
208
+ async function validateProofCredential(
209
+ credential: Credential,
210
+ payload: ProofPayload,
211
+ context: CredentialContext,
212
+ ) {
213
+ const { chainId, challenge, client, isZeroAmount } = context
214
+ if (!isZeroAmount)
215
+ throw new MismatchError('Proof credentials are only valid for zero-amount challenges.', {})
216
+
217
+ const expectedSource = credential.source
218
+ if (!expectedSource) throw new MismatchError('Proof credential must include a source.', {})
219
+
220
+ const resolvedChainId = challenge.request.methodDetails?.chainId ?? chainId!
221
+ const source = Proof.parsePkhSource(expectedSource)
222
+ if (!source || source.chainId !== resolvedChainId)
223
+ throw new MismatchError('Proof credential source is invalid.', {})
224
+
225
+ const valid = await verifyTypedData(client, {
226
+ address: source.address,
227
+ ...Proof.typedData({
228
+ account: source.address,
229
+ chainId: resolvedChainId,
230
+ challengeId: challenge.id,
231
+ realm: challenge.realm,
232
+ }),
233
+ signature: payload.signature as `0x${string}`,
234
+ })
235
+ if (!valid) {
236
+ const proofSigner = recoverAuthorizedProofSigner({
237
+ chainId: resolvedChainId,
238
+ challengeId: challenge.id,
239
+ realm: challenge.realm,
240
+ signature: payload.signature as `0x${string}`,
241
+ sourceAddress: source.address,
242
+ })
243
+ const authorized = proofSigner
244
+ ? await isActiveAccessKey(client, { accessKey: proofSigner, account: source.address })
245
+ : false
246
+ if (!authorized) throw new MismatchError('Proof signature does not match source.', {})
247
+ }
248
+ return { sender: source.address }
249
+ }
250
+
251
+ async function validateTransactionCredential(
252
+ credential: Credential,
253
+ payload: TransactionPayload,
254
+ request: Method.VerifyContext<typeof Methods.charge>['request'],
255
+ context: CredentialContext,
256
+ ) {
257
+ const {
258
+ amount,
259
+ chainId,
260
+ challenge,
261
+ client,
262
+ currency,
263
+ memo,
264
+ methodDetails,
265
+ recipient,
266
+ requestAllowsFeePayer,
267
+ supportedModes,
268
+ } = context
269
+ if (supportedModes && !supportedModes.includes('pull'))
270
+ throw new MismatchError('Transaction credentials are not supported for this challenge.', {})
271
+
272
+ const serializedTransaction = payload.signature as Transaction.TransactionSerializedTempo
273
+ if (!FeePayer.isTempoTransaction(serializedTransaction))
274
+ throw new MismatchError('Only Tempo (0x76/0x78) transactions are supported.', {})
275
+
276
+ const transaction = Transaction.deserialize(serializedTransaction)
277
+ if (!transaction.signature || !transaction.from)
278
+ throw new MismatchError(
279
+ 'Transaction must be signed by the sender before fee payer co-signing.',
280
+ {},
281
+ )
282
+
283
+ const isFeePayerTx =
284
+ methodDetails?.feePayer === true &&
285
+ requestAllowsFeePayer &&
286
+ !!(typeof request.feePayer === 'object' ? request.feePayer : feePayer || feePayerUrl)
287
+ const transfers = getExpectedTransfers({ amount, memo, methodDetails, recipient })
288
+ const matchedCalls = assertTransferCalls(transaction.calls ?? [], {
289
+ currency,
290
+ exactCount: isFeePayerTx,
291
+ transfers,
292
+ })
293
+ if (!memo)
294
+ assertChallengeBoundCallMemo(matchedCalls, {
295
+ challengeId: challenge.id,
296
+ realm: challenge.realm,
297
+ })
298
+
299
+ if (isFeePayerTx) {
300
+ FeePayer.validateCalls(
301
+ transaction.calls,
302
+ { amount, currency, recipient },
303
+ { currency, expectedTransfers: transfers },
304
+ )
305
+ FeePayer.assertAllowedFeeToken(transaction, FeePayer.defaultAllowedFeeTokens(chainId))
306
+ } else {
307
+ await viem_call(
308
+ client,
309
+ FeePayer.simulationTransaction(transaction, { feePayer: false }) as never,
310
+ )
311
+ }
312
+
313
+ return { isFeePayerTx, serializedTransaction, transaction, transfers }
314
+ }
315
+
316
+ async function validateCredential(
317
+ credential: Credential,
318
+ request: Method.VerifyContext<typeof Methods.charge>['request'],
319
+ context: CredentialContext,
320
+ ) {
321
+ switch (context.payload.type) {
322
+ case 'hash': {
323
+ const { receipt, sender, transfers } = await validateHashCredential(
324
+ credential,
325
+ context.payload,
326
+ context,
327
+ )
328
+ return {
329
+ details: { mode: 'push' as const, sender, transfers },
330
+ hash: context.payload.hash as `0x${string}`,
331
+ receipt,
332
+ type: 'hash' as const,
333
+ }
334
+ }
335
+
336
+ case 'proof': {
337
+ const { sender } = await validateProofCredential(credential, context.payload, context)
338
+ return { details: { mode: 'proof' as const, sender }, type: 'proof' as const }
339
+ }
340
+
341
+ case 'transaction': {
342
+ const { isFeePayerTx, serializedTransaction, transaction, transfers } =
343
+ await validateTransactionCredential(credential, context.payload, request, context)
344
+ return {
345
+ details: {
346
+ mode: 'pull' as const,
347
+ sender: transaction.from as `0x${string}`,
348
+ serializedTransaction,
349
+ transfers,
350
+ },
351
+ isFeePayerTx,
352
+ serializedTransaction,
353
+ transaction,
354
+ transfers,
355
+ type: 'transaction' as const,
356
+ }
357
+ }
358
+ }
359
+ }
360
+
94
361
  type Defaults = charge.DeriveDefaults<parameters>
95
- return Method.toServer<typeof Methods.charge, Defaults>(Methods.charge, {
362
+ const method = Method.toServer<typeof Methods.charge, Defaults>(Methods.charge, {
96
363
  defaults: {
97
364
  amount,
98
365
  currency,
@@ -172,25 +439,35 @@ export function charge<const parameters extends charge.Parameters>(
172
439
  }
173
440
  },
174
441
 
175
- async verify({ credential, request }) {
176
- const { challenge } = credential
177
- const resolvedRequest = (() => {
178
- const parsed = Methods.charge.schema.request.safeParse(request)
179
- if (parsed.success) return parsed.data
180
- // verifyCredential() passes the HMAC-bound challenge request, which is
181
- // already in canonical output form and should not be transformed again.
182
- return request as unknown as z.output<typeof Methods.charge.schema.request>
183
- })()
184
- const chainId = resolvedRequest.methodDetails?.chainId ?? request.chainId
442
+ async validate({ credential, request }) {
443
+ const context = await resolveCredentialContext({ credential, request })
444
+ const validated = await validateCredential(credential, request, context)
185
445
 
186
- const client = await getClient({ chainId })
446
+ return {
447
+ challenge: context.challenge,
448
+ credential,
449
+ details: validated.details,
450
+ intent: 'charge',
451
+ method: 'tempo',
452
+ request: context.resolvedRequest,
453
+ source: credential.source,
454
+ }
455
+ },
187
456
 
188
- const { amount, methodDetails } = resolvedRequest
189
- const requestAllowsFeePayer =
190
- request.feePayer !== false &&
191
- (request.feePayer === undefined ||
192
- request.feePayer === true ||
193
- typeof request.feePayer === 'object')
457
+ async broadcast({ credential, request }) {
458
+ const context = await resolveCredentialContext({ credential, request })
459
+ const {
460
+ amount,
461
+ chainId,
462
+ challenge,
463
+ client,
464
+ currency,
465
+ memo,
466
+ methodDetails,
467
+ recipient,
468
+ requestAllowsFeePayer,
469
+ } = context
470
+ const validated = await validateCredential(credential, request, context)
194
471
  const feePayerAccount =
195
472
  methodDetails?.feePayer === true && requestAllowsFeePayer
196
473
  ? typeof request.feePayer === 'object'
@@ -198,133 +475,23 @@ export function charge<const parameters extends charge.Parameters>(
198
475
  : feePayer
199
476
  : undefined
200
477
  const expires = challenge.expires
201
- const supportedModes = methodDetails?.supportedModes as
202
- | readonly Methods.ChargeMode[]
203
- | undefined
204
-
205
- const currency = resolvedRequest.currency as `0x${string}`
206
- const recipient = resolvedRequest.recipient as `0x${string}`
207
-
208
- Expires.assert(expires, challenge.id)
209
-
210
- const memo = methodDetails?.memo as `0x${string}` | undefined
211
-
212
- const payload = credential.payload
213
- const isZeroAmount = BigInt(amount) === 0n
214
-
215
- if (isZeroAmount && payload.type !== 'proof')
216
- throw new MismatchError('Zero-amount challenges require a proof credential.', {})
217
478
 
218
- switch (payload.type) {
479
+ switch (validated.type) {
219
480
  case 'hash': {
220
- if (supportedModes && !supportedModes.includes('push'))
221
- throw new MismatchError('Hash credentials are not supported for this challenge.', {})
222
-
223
- const hash = payload.hash as `0x${string}`
224
- // Validate client-supplied identity before reserving the hash so a
225
- // malformed source cannot burn an otherwise valid payment attempt.
226
- const source = parseHashCredentialSource({
227
- chainId: chainId ?? client.chain?.id,
228
- source: credential.source,
229
- })
230
- // Reserve the hash while we verify it. This blocks concurrent
231
- // requests from racing to reuse the same on-chain payment.
481
+ const { hash, receipt } = validated
232
482
  if (!(await markHashUsed(store, hash))) {
233
- throw new VerificationFailedError({ reason: 'Transaction hash has already been used' })
234
- }
235
-
236
- // If verification fails after reservation, release it so transient
237
- // RPC/log-validation errors do not force the payer to pay again.
238
- // Once we have proven the receipt is a successful matching payment,
239
- // keep the marker to enforce single-use semantics.
240
- let releaseReservation = true
241
-
242
- try {
243
- const expectedTransfers = getExpectedTransfers({
244
- amount,
245
- memo,
246
- methodDetails,
247
- recipient,
248
- })
249
- const receipt = await getTransactionReceipt(client, { hash })
250
- const sender = source?.address ?? receipt.from
251
- const matchedLogs = await assertTransferLogs(receipt, {
252
- currency,
253
- sender,
254
- source,
255
- transfers: expectedTransfers,
256
- validateSender,
483
+ throw new VerificationFailedError({
484
+ reason: 'Transaction hash has already been used',
257
485
  })
258
- // Only verify challenge binding when using auto-generated attribution memos.
259
- // Explicit memos (set by the server) are strictly matched by assertTransferLogs
260
- // but are NOT challenge-bound — callers that set explicit memos are responsible
261
- // for ensuring memo uniqueness per challenge to prevent cross-challenge hash reuse.
262
- if (!memo)
263
- assertChallengeBoundMemo(matchedLogs, {
264
- challengeId: challenge.id,
265
- realm: challenge.realm,
266
- })
267
-
268
- const paymentReceipt = toReceipt(receipt)
269
- // `toReceipt` can throw for reverted transactions. Only keep the
270
- // reservation after it confirms the referenced transaction settled.
271
- releaseReservation = false
272
- return paymentReceipt
273
- } catch (error) {
274
- if (releaseReservation) await releaseHashUse(store, hash)
275
- throw error
276
486
  }
487
+ return receipt
277
488
  }
278
489
 
279
490
  case 'proof': {
280
- if (!isZeroAmount)
281
- throw new MismatchError(
282
- 'Proof credentials are only valid for zero-amount challenges.',
283
- {},
284
- )
285
-
286
- const expectedSource = credential.source
287
- if (!expectedSource)
288
- throw new MismatchError('Proof credential must include a source.', {})
289
-
290
- const resolvedChainId = challenge.request.methodDetails?.chainId ?? chainId!
291
- const source = Proof.parsePkhSource(expectedSource)
292
-
293
- if (!source || source.chainId !== resolvedChainId) {
294
- throw new MismatchError('Proof credential source is invalid.', {})
295
- }
296
-
297
- const valid = await verifyTypedData(client, {
298
- // Bind verification to the claimed payer (`source.address`): the
299
- // signer may be the payer itself or an access key authorized for it.
300
- address: source.address,
301
- ...Proof.typedData({
302
- account: source.address,
303
- chainId: resolvedChainId,
304
- challengeId: challenge.id,
305
- realm: challenge.realm,
306
- }),
307
- signature: payload.signature as `0x${string}`,
308
- })
309
- if (!valid) {
310
- const proofSigner = recoverAuthorizedProofSigner({
311
- chainId: resolvedChainId,
312
- challengeId: challenge.id,
313
- realm: challenge.realm,
314
- signature: payload.signature as `0x${string}`,
315
- sourceAddress: source.address,
316
- })
317
- const authorized = proofSigner
318
- ? await isActiveAccessKey(client, {
319
- accessKey: proofSigner,
320
- account: source.address,
321
- })
322
- : false
323
- if (!authorized) throw new MismatchError('Proof signature does not match source.', {})
324
- }
325
-
326
491
  if (proofStore && !(await markProofUsed(proofStore, challenge.id))) {
327
- throw new VerificationFailedError({ reason: 'Proof credential has already been used' })
492
+ throw new VerificationFailedError({
493
+ reason: 'Proof credential has already been used',
494
+ })
328
495
  }
329
496
 
330
497
  return {
@@ -336,82 +503,26 @@ export function charge<const parameters extends charge.Parameters>(
336
503
  }
337
504
 
338
505
  case 'transaction': {
339
- if (supportedModes && !supportedModes.includes('pull'))
340
- throw new MismatchError(
341
- 'Transaction credentials are not supported for this challenge.',
342
- {},
343
- )
344
-
345
- const serializedTransaction = payload.signature as Transaction.TransactionSerializedTempo
506
+ const { isFeePayerTx, serializedTransaction, transaction, transfers } = validated
346
507
 
347
508
  // Pre-broadcast dedup: catch exact byte-for-byte replays early.
348
509
  const hash = keccak256(serializedTransaction)
349
510
  if (!(await markHashUsed(store, hash))) {
350
- throw new VerificationFailedError({ reason: 'Transaction hash has already been used' })
511
+ throw new VerificationFailedError({
512
+ reason: 'Transaction hash has already been used',
513
+ })
351
514
  }
352
515
 
353
- let releaseReservation = true
354
- let sponsoredSenderReservation: { chainId: number; sender: `0x${string}` } | undefined
516
+ let broadcastAttempted = false
517
+ let finalHash: `0x${string}` | undefined
518
+ let reservation: SponsorBudget.Handle | undefined
355
519
 
356
520
  try {
357
- if (!FeePayer.isTempoTransaction(serializedTransaction))
358
- throw new MismatchError('Only Tempo (0x76/0x78) transactions are supported.', {})
359
-
360
- const transaction = Transaction.deserialize(serializedTransaction)
361
- if (!transaction.signature || !transaction.from)
362
- throw new MismatchError(
363
- 'Transaction must be signed by the sender before fee payer co-signing.',
364
- {},
365
- )
366
-
367
- const calls = (transaction.calls ?? []) as readonly {
368
- data?: `0x${string}` | undefined
369
- to?: `0x${string}` | undefined
370
- }[]
371
- const transfers = getExpectedTransfers({ amount, memo, methodDetails, recipient })
372
- const isFeePayerTx =
373
- methodDetails?.feePayer === true &&
374
- requestAllowsFeePayer &&
375
- !!(feePayerAccount || feePayerUrl)
376
- const matchedCalls = assertTransferCalls(calls, {
377
- currency,
378
- exactCount: isFeePayerTx,
379
- transfers,
380
- })
381
- if (!memo)
382
- assertChallengeBoundCallMemo(matchedCalls, {
383
- challengeId: challenge.id,
384
- realm: challenge.realm,
385
- })
386
-
387
- if (isFeePayerTx) {
388
- const reservationChainId = chainId ?? client.chain!.id
389
- if (
390
- !(await markSponsoredSenderInFlight(store, {
391
- chainId: reservationChainId,
392
- sender: transaction.from as `0x${string}`,
393
- }))
394
- ) {
395
- throw new VerificationFailedError({
396
- reason: 'Sponsored transaction from this sender is already in flight',
397
- })
398
- }
399
- sponsoredSenderReservation = {
400
- chainId: reservationChainId,
401
- sender: transaction.from as `0x${string}`,
402
- }
403
- FeePayer.validateCalls(
404
- transaction.calls,
405
- { amount, currency, recipient },
406
- { currency, expectedTransfers: transfers },
407
- )
408
- }
409
-
410
521
  const allowedFeeTokens = FeePayer.defaultAllowedFeeTokens(chainId)
411
522
  if (isFeePayerTx) FeePayer.assertAllowedFeeToken(transaction, allowedFeeTokens)
412
523
  const selectableFeeTokens = allowedFeeTokens as readonly `0x${string}`[]
413
524
 
414
- const serializedTransaction_final = await (async () => {
525
+ const completedTransaction = await (async () => {
415
526
  if (feePayerAccount && methodDetails?.feePayer !== false) {
416
527
  const completed = await FeePayer.preflightSponsorship({
417
528
  transaction,
@@ -432,7 +543,7 @@ export function charge<const parameters extends charge.Parameters>(
432
543
  challengeExpires: expires,
433
544
  chainId: chainId ?? client.chain!.id,
434
545
  details: { amount, currency, recipient },
435
- policy: feePayerPolicy,
546
+ policy: transactionFeePayerPolicy,
436
547
  transaction: {
437
548
  ...transaction,
438
549
  ...(feeToken ? { feeToken } : {}),
@@ -441,7 +552,13 @@ export function charge<const parameters extends charge.Parameters>(
441
552
  return { feePayer: feePayerAccount.address, transaction: sponsored }
442
553
  },
443
554
  })
444
- return signTransaction(client, completed.transaction as never)
555
+ return {
556
+ serializedTransaction: await signTransaction(
557
+ client,
558
+ completed.transaction as never,
559
+ ),
560
+ sponsor: completed.feePayer,
561
+ }
445
562
  }
446
563
  if (feePayerUrl && isFeePayerTx) {
447
564
  const completed = await FeePayer.preflightSponsorship({
@@ -453,17 +570,66 @@ export function charge<const parameters extends charge.Parameters>(
453
570
  challengeExpires: expires,
454
571
  chainId: chainId ?? client.chain!.id,
455
572
  details: { amount, currency, recipient },
456
- policy: feePayerPolicy,
573
+ policy: transactionFeePayerPolicy,
457
574
  transaction,
458
575
  url: feePayerUrl,
459
576
  })
460
577
  return { ...hosted, transaction }
461
578
  },
462
579
  })
463
- return completed.serializedTransaction
580
+ return {
581
+ serializedTransaction: completed.serializedTransaction,
582
+ sponsor: completed.feePayer,
583
+ }
464
584
  }
465
- return serializedTransaction
585
+ return { serializedTransaction, sponsor: undefined }
466
586
  })()
587
+ const serializedTransaction_final = completedTransaction.serializedTransaction
588
+ finalHash = keccak256(serializedTransaction_final)
589
+
590
+ if (
591
+ finalHash.toLowerCase() !== hash.toLowerCase() &&
592
+ !(await markHashUsed(store, finalHash))
593
+ )
594
+ throw new VerificationFailedError({
595
+ reason: 'Transaction hash has already been used',
596
+ })
597
+
598
+ if (isFeePayerTx) {
599
+ const sponsor = completedTransaction.sponsor
600
+ if (!sponsor)
601
+ throw new VerificationFailedError({
602
+ reason: 'Sponsored transaction has no configured fee payer',
603
+ })
604
+ const reservationChainId = chainId ?? client.chain!.id
605
+ const { totalFee, validBeforeValue } = FeePayer.assertTransactionPolicy({
606
+ challengeExpires: expires,
607
+ chainId: reservationChainId,
608
+ details: { amount, currency, recipient },
609
+ policy: transactionFeePayerPolicy,
610
+ transaction,
611
+ })
612
+ const expiresAt = validBeforeValue * 1_000
613
+ const waitUntil = Math.min(
614
+ expiresAt,
615
+ expires ? Date.parse(expires) : Number.MAX_SAFE_INTEGER,
616
+ )
617
+ reservation = await SponsorBudget.reserve(sponsorBudgetStore, {
618
+ chainId: reservationChainId,
619
+ expiresAt,
620
+ fee: totalFee,
621
+ getReceipt: (transactionHash) =>
622
+ getTransactionReceipt(client, { hash: transactionHash }),
623
+ id: finalHash.toLowerCase(),
624
+ maxReservations: maxInFlightReservations,
625
+ maxTotalFee: maxInFlightTotalFee,
626
+ owner: globalThis.crypto.randomUUID(),
627
+ sponsor,
628
+ transactionHash: finalHash,
629
+ waitUntil,
630
+ })
631
+ Expires.assert(challenge.expires, challenge.id)
632
+ }
467
633
 
468
634
  // Pre-broadcast simulation for non-sponsored transactions.
469
635
  if (!isFeePayerTx)
@@ -472,10 +638,20 @@ export function charge<const parameters extends charge.Parameters>(
472
638
  FeePayer.simulationTransaction(transaction, { feePayer: false }) as never,
473
639
  )
474
640
 
641
+ if (
642
+ reservation &&
643
+ !(await SponsorBudget.transition(sponsorBudgetStore, reservation, 'broadcasting'))
644
+ )
645
+ throw new VerificationFailedError({
646
+ reason: 'Sponsor budget reservation ownership was lost before broadcast',
647
+ })
648
+
649
+ broadcastAttempted = true
475
650
  if (waitForConfirmation) {
476
651
  const receipt = await sendRawTransactionSync(client, {
477
652
  serializedTransaction: serializedTransaction_final,
478
653
  })
654
+ if (reservation) await SponsorBudget.release(sponsorBudgetStore, reservation)
479
655
  const matchedLogs = await assertTransferLogs(receipt, {
480
656
  currency,
481
657
  sender: transaction.from! as `0x${string}`,
@@ -486,19 +662,10 @@ export function charge<const parameters extends charge.Parameters>(
486
662
  challengeId: challenge.id,
487
663
  realm: challenge.realm,
488
664
  })
489
- // Post-broadcast dedup: catch malleable input variants
490
- // (different serialized bytes, same underlying tx) that
491
- // bypass the pre-broadcast check. Skip if the broadcast
492
- // hash matches the input hash (already stored above).
493
- if (
494
- receipt.transactionHash.toLowerCase() !== hash.toLowerCase() &&
495
- !(await markHashUsed(store, receipt.transactionHash))
496
- ) {
665
+ if (receipt.transactionHash.toLowerCase() !== finalHash.toLowerCase())
497
666
  throw new VerificationFailedError({
498
- reason: 'Transaction hash has already been used',
667
+ reason: 'Broadcast transaction hash does not match the signed transaction',
499
668
  })
500
- }
501
- releaseReservation = false
502
669
  return toReceipt(receipt)
503
670
  }
504
671
 
@@ -508,16 +675,17 @@ export function charge<const parameters extends charge.Parameters>(
508
675
  const reference = await sendRawTransaction(client, {
509
676
  serializedTransaction: serializedTransaction_final,
510
677
  })
511
- // Post-broadcast dedup: same
678
+ if (reference.toLowerCase() !== finalHash.toLowerCase())
679
+ throw new VerificationFailedError({
680
+ reason: 'Broadcast transaction hash does not match the signed transaction',
681
+ })
512
682
  if (
513
- reference.toLowerCase() !== hash.toLowerCase() &&
514
- !(await markHashUsed(store, reference))
515
- ) {
683
+ reservation &&
684
+ !(await SponsorBudget.transition(sponsorBudgetStore, reservation, 'pending'))
685
+ )
516
686
  throw new VerificationFailedError({
517
- reason: 'Transaction hash has already been used',
687
+ reason: 'Sponsor budget reservation ownership was lost after broadcast',
518
688
  })
519
- }
520
- releaseReservation = false
521
689
  return {
522
690
  method: 'tempo',
523
691
  status: 'success',
@@ -525,28 +693,37 @@ export function charge<const parameters extends charge.Parameters>(
525
693
  reference,
526
694
  } as const
527
695
  } catch (error) {
528
- if (releaseReservation) await releaseHashUse(store, hash)
696
+ if (!broadcastAttempted) {
697
+ if (reservation) await SponsorBudget.release(sponsorBudgetStore, reservation)
698
+ if (finalHash && finalHash.toLowerCase() !== hash.toLowerCase())
699
+ await releaseHashUse(store, finalHash)
700
+ await releaseHashUse(store, hash)
701
+ }
529
702
  throw error
530
- } finally {
531
- if (sponsoredSenderReservation)
532
- await releaseSponsoredSenderInFlight(store, sponsoredSenderReservation)
533
703
  }
534
704
  }
535
-
536
- default:
537
- throw new Error(`Unsupported credential type "${(payload as { type: string }).type}".`)
538
705
  }
539
706
  },
540
707
  })
708
+ return relay ? (Relay.configure(method, relay) as typeof method) : method
541
709
  }
542
710
 
543
711
  export declare namespace charge {
544
- type StoreItemMap = { [key: `mppx:charge:${string}`]: number }
712
+ type StoreItemMap = {
713
+ [key: `mppx:charge:${string}`]: number | SponsorBudget.State
714
+ }
545
715
 
546
716
  type Defaults = LooseOmit<Method.RequestDefaults<typeof Methods.charge>, 'feePayer' | 'recipient'>
547
717
 
548
718
  type ValidateSender = (parameters: ValidateSenderParameters) => boolean | Promise<boolean>
549
719
 
720
+ type ValidationDetails = {
721
+ mode: 'proof' | 'pull' | 'push'
722
+ sender?: `0x${string}` | undefined
723
+ serializedTransaction?: Transaction.TransactionSerializedTempo | undefined
724
+ transfers?: readonly ExpectedTransfer[] | undefined
725
+ }
726
+
550
727
  type ValidateSenderParameters = {
551
728
  /** Actual TIP-20 `Transfer.from` address. */
552
729
  sender: `0x${string}`
@@ -562,10 +739,13 @@ export declare namespace charge {
562
739
  /**
563
740
  * Override the fee-sponsor policy used when co-signing Tempo charge
564
741
  * transactions. Defaults resolve per chain, including a higher
565
- * priority-fee ceiling on Moderato.
742
+ * priority-fee ceiling on Moderato and a bounded aggregate in-flight fee
743
+ * budget. Sponsored transactions reserve their declared maximum fee
744
+ * atomically; independent expiring-nonce transactions run concurrently
745
+ * while capacity remains and wait for capacity when the budget is full.
566
746
  *
567
- * If you increase `maxGas` or `maxFeePerGas`, you may also need to raise
568
- * `maxTotalFee` so the combined fee budget remains valid.
747
+ * If you increase `maxGas`, `maxFeePerGas`, or `maxTotalFee`, you may also
748
+ * need to raise `maxInFlightTotalFee`.
569
749
  */
570
750
  feePayerPolicy?: FeePayerPolicy | undefined
571
751
  /**
@@ -605,6 +785,8 @@ export declare namespace charge {
605
785
  * By default, no prefix is applied.
606
786
  */
607
787
  storeKeyPrefix?: string | undefined
788
+ /** Delegates Tempo charge credential validation and broadcast to Tempo API or a compatible MPP relay. */
789
+ relay?: RelayOptions | undefined
608
790
  /**
609
791
  * Whether to wait for the charge transaction to confirm on-chain before
610
792
  * responding. @default true
@@ -630,7 +812,19 @@ export declare namespace charge {
630
812
  }
631
813
  >
632
814
 
633
- type FeePayerPolicy = Partial<FeePayer.Policy>
815
+ type FeePayerPolicy = Partial<FeePayer.Policy> & {
816
+ /** Maximum number of sponsored transactions awaiting a terminal receipt. @default 100 */
817
+ maxInFlightReservations?: number | undefined
818
+ /**
819
+ * Maximum aggregate declared fee exposure awaiting terminal receipts.
820
+ *
821
+ * @default `maxTotalFee * 10`
822
+ */
823
+ maxInFlightTotalFee?: bigint | undefined
824
+ }
825
+
826
+ /** Tempo API relay configuration for server-side charges. */
827
+ type RelayOptions = Relay.configure.Options
634
828
  }
635
829
 
636
830
  type ChargeRequest = z.output<typeof Methods.charge.schema.request>
@@ -971,14 +1165,6 @@ function getProofStoreKey(challengeId: string): `mppx:charge:${string}` {
971
1165
  return `mppx:charge:proof:${challengeId}`
972
1166
  }
973
1167
 
974
- /** @internal */
975
- function getSponsoredSenderStoreKey(parameters: {
976
- chainId: number
977
- sender: `0x${string}`
978
- }): `mppx:charge:${string}` {
979
- return `mppx:charge:sponsor:${parameters.chainId}:${parameters.sender.toLowerCase()}`
980
- }
981
-
982
1168
  async function markHashUsed(
983
1169
  store: Store.AtomicStore<charge.StoreItemMap>,
984
1170
  hash: `0x${string}`,
@@ -1012,25 +1198,6 @@ function parseHashCredentialSource(parameters: {
1012
1198
  return parsed
1013
1199
  }
1014
1200
 
1015
- /** @internal */
1016
- async function markSponsoredSenderInFlight(
1017
- store: Store.AtomicStore<charge.StoreItemMap>,
1018
- parameters: { chainId: number; sender: `0x${string}` },
1019
- ): Promise<boolean> {
1020
- return store.update(getSponsoredSenderStoreKey(parameters), (current) => {
1021
- if (current !== null) return { op: 'noop', result: false }
1022
- return { op: 'set', value: Date.now(), result: true }
1023
- })
1024
- }
1025
-
1026
- /** @internal */
1027
- async function releaseSponsoredSenderInFlight(
1028
- store: Store.AtomicStore<charge.StoreItemMap>,
1029
- parameters: { chainId: number; sender: `0x${string}` },
1030
- ): Promise<void> {
1031
- await store.delete(getSponsoredSenderStoreKey(parameters))
1032
- }
1033
-
1034
1201
  /** @internal */
1035
1202
  async function markProofUsed(
1036
1203
  store: Store.AtomicStore<charge.StoreItemMap>,
@@ -1170,6 +1337,7 @@ class MismatchError extends PaymentError {
1170
1337
  : `Payment verification failed: ${reason}`,
1171
1338
  ...Object.entries(details).map(([k, v]) => ` - ${k}: ${v}`),
1172
1339
  ].join('\n'),
1340
+ { details },
1173
1341
  )
1174
1342
  }
1175
1343
  }