mppx 0.8.13 → 0.8.15

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 (156) hide show
  1. package/CHANGELOG.md +37 -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/cli/validate/payment.d.ts.map +1 -1
  11. package/dist/cli/validate/payment.js +18 -4
  12. package/dist/cli/validate/payment.js.map +1 -1
  13. package/dist/client/internal/Fetch.d.ts.map +1 -1
  14. package/dist/client/internal/Fetch.js +98 -5
  15. package/dist/client/internal/Fetch.js.map +1 -1
  16. package/dist/client/internal/MethodChallenge.d.ts +19 -0
  17. package/dist/client/internal/MethodChallenge.d.ts.map +1 -0
  18. package/dist/client/internal/MethodChallenge.js +15 -0
  19. package/dist/client/internal/MethodChallenge.js.map +1 -0
  20. package/dist/client/internal/MethodResponse.d.ts +15 -0
  21. package/dist/client/internal/MethodResponse.d.ts.map +1 -1
  22. package/dist/client/internal/MethodResponse.js +10 -0
  23. package/dist/client/internal/MethodResponse.js.map +1 -1
  24. package/dist/server/Mppx.d.ts +23 -4
  25. package/dist/server/Mppx.d.ts.map +1 -1
  26. package/dist/server/Mppx.js +91 -82
  27. package/dist/server/Mppx.js.map +1 -1
  28. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  29. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  30. package/dist/stripe/server/internal/html.gen.js +1 -1
  31. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  32. package/dist/tempo/client/Methods.d.ts +1 -0
  33. package/dist/tempo/client/Methods.d.ts.map +1 -1
  34. package/dist/tempo/internal/fee-payer.d.ts +1 -0
  35. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  36. package/dist/tempo/internal/fee-payer.js +7 -2
  37. package/dist/tempo/internal/fee-payer.js.map +1 -1
  38. package/dist/tempo/legacy/client/ChannelOps.d.ts.map +1 -1
  39. package/dist/tempo/legacy/client/ChannelOps.js +2 -1
  40. package/dist/tempo/legacy/client/ChannelOps.js.map +1 -1
  41. package/dist/tempo/server/Charge.d.ts +46 -77
  42. package/dist/tempo/server/Charge.d.ts.map +1 -1
  43. package/dist/tempo/server/Charge.js +289 -209
  44. package/dist/tempo/server/Charge.js.map +1 -1
  45. package/dist/tempo/server/Methods.d.ts +12 -1
  46. package/dist/tempo/server/Methods.d.ts.map +1 -1
  47. package/dist/tempo/server/Methods.js +4 -0
  48. package/dist/tempo/server/Methods.js.map +1 -1
  49. package/dist/tempo/server/Relay.d.ts +57 -0
  50. package/dist/tempo/server/Relay.d.ts.map +1 -0
  51. package/dist/tempo/server/Relay.js +179 -0
  52. package/dist/tempo/server/Relay.js.map +1 -0
  53. package/dist/tempo/server/SponsorBudget.d.ts +59 -0
  54. package/dist/tempo/server/SponsorBudget.d.ts.map +1 -0
  55. package/dist/tempo/server/SponsorBudget.js +150 -0
  56. package/dist/tempo/server/SponsorBudget.js.map +1 -0
  57. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  58. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  59. package/dist/tempo/server/internal/html.gen.js +1 -1
  60. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  61. package/dist/tempo/session/client/ChannelOps.d.ts +7 -1
  62. package/dist/tempo/session/client/ChannelOps.d.ts.map +1 -1
  63. package/dist/tempo/session/client/ChannelOps.js +9 -4
  64. package/dist/tempo/session/client/ChannelOps.js.map +1 -1
  65. package/dist/tempo/session/client/CredentialState.d.ts +5 -1
  66. package/dist/tempo/session/client/CredentialState.d.ts.map +1 -1
  67. package/dist/tempo/session/client/CredentialState.js +37 -19
  68. package/dist/tempo/session/client/CredentialState.js.map +1 -1
  69. package/dist/tempo/session/client/Runtime.d.ts +8 -0
  70. package/dist/tempo/session/client/Runtime.d.ts.map +1 -1
  71. package/dist/tempo/session/client/Runtime.js +17 -0
  72. package/dist/tempo/session/client/Runtime.js.map +1 -1
  73. package/dist/tempo/session/client/Session.d.ts +15 -5
  74. package/dist/tempo/session/client/Session.d.ts.map +1 -1
  75. package/dist/tempo/session/client/Session.js +216 -45
  76. package/dist/tempo/session/client/Session.js.map +1 -1
  77. package/dist/tempo/session/client/SessionManager.d.ts +8 -1
  78. package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
  79. package/dist/tempo/session/client/SessionManager.js +114 -44
  80. package/dist/tempo/session/client/SessionManager.js.map +1 -1
  81. package/dist/tempo/session/client/Transports.d.ts +16 -6
  82. package/dist/tempo/session/client/Transports.d.ts.map +1 -1
  83. package/dist/tempo/session/client/Transports.js +84 -37
  84. package/dist/tempo/session/client/Transports.js.map +1 -1
  85. package/dist/tempo/session/precompile/Chain.d.ts.map +1 -1
  86. package/dist/tempo/session/precompile/Chain.js +96 -8
  87. package/dist/tempo/session/precompile/Chain.js.map +1 -1
  88. package/dist/tempo/session/precompile/Channel.d.ts +2 -0
  89. package/dist/tempo/session/precompile/Channel.d.ts.map +1 -1
  90. package/dist/tempo/session/precompile/Channel.js +4 -0
  91. package/dist/tempo/session/precompile/Channel.js.map +1 -1
  92. package/dist/tempo/session/server/RequestState.js +3 -1
  93. package/dist/tempo/session/server/RequestState.js.map +1 -1
  94. package/dist/tempo/session/server/Ws.d.ts.map +1 -1
  95. package/dist/tempo/session/server/Ws.js +3 -0
  96. package/dist/tempo/session/server/Ws.js.map +1 -1
  97. package/dist/tempo/subscription/KeyAuthorization.d.ts +1 -829
  98. package/dist/tempo/subscription/KeyAuthorization.d.ts.map +1 -1
  99. package/dist/tempo/subscription/KeyAuthorization.js +14 -3
  100. package/dist/tempo/subscription/KeyAuthorization.js.map +1 -1
  101. package/dist/viem/Client.d.ts.map +1 -1
  102. package/dist/viem/Client.js +6 -1
  103. package/dist/viem/Client.js.map +1 -1
  104. package/package.json +2 -2
  105. package/src/Errors.test.ts +23 -0
  106. package/src/Errors.ts +21 -2
  107. package/src/Method.test.ts +102 -1
  108. package/src/Method.ts +241 -5
  109. package/src/cli/validate/payment.ts +18 -4
  110. package/src/cli/validate.test.ts +16 -0
  111. package/src/client/Mppx.test-d.ts +3 -3
  112. package/src/client/internal/Fetch.test.ts +77 -0
  113. package/src/client/internal/Fetch.ts +122 -11
  114. package/src/client/internal/MethodChallenge.ts +35 -0
  115. package/src/client/internal/MethodResponse.ts +28 -0
  116. package/src/server/Mppx.test-d.ts +2 -0
  117. package/src/server/Mppx.test.ts +205 -0
  118. package/src/server/Mppx.ts +167 -103
  119. package/src/stripe/server/internal/html.gen.ts +1 -1
  120. package/src/tempo/PublicExports.test-d.ts +20 -0
  121. package/src/tempo/internal/fee-payer.ts +7 -3
  122. package/src/tempo/legacy/client/ChannelOps.test.ts +7 -0
  123. package/src/tempo/legacy/client/ChannelOps.ts +2 -1
  124. package/src/tempo/server/Charge.test.ts +337 -63
  125. package/src/tempo/server/Charge.ts +443 -267
  126. package/src/tempo/server/Methods.ts +11 -0
  127. package/src/tempo/server/Relay.test.ts +677 -0
  128. package/src/tempo/server/Relay.ts +297 -0
  129. package/src/tempo/server/SponsorBudget.test.ts +154 -0
  130. package/src/tempo/server/SponsorBudget.ts +219 -0
  131. package/src/tempo/server/internal/html.gen.ts +1 -1
  132. package/src/tempo/session/README.md +7 -0
  133. package/src/tempo/session/client/ChannelOps.test.ts +17 -2
  134. package/src/tempo/session/client/ChannelOps.ts +12 -4
  135. package/src/tempo/session/client/CredentialState.ts +61 -20
  136. package/src/tempo/session/client/Runtime.test.ts +12 -0
  137. package/src/tempo/session/client/Runtime.ts +22 -0
  138. package/src/tempo/session/client/Session.test.ts +506 -7
  139. package/src/tempo/session/client/Session.ts +279 -54
  140. package/src/tempo/session/client/SessionManager.test.ts +318 -13
  141. package/src/tempo/session/client/SessionManager.ts +132 -42
  142. package/src/tempo/session/client/Transports.test.ts +54 -1
  143. package/src/tempo/session/client/Transports.ts +137 -57
  144. package/src/tempo/session/precompile/Chain.integration.test.ts +19 -1
  145. package/src/tempo/session/precompile/Chain.test.ts +197 -10
  146. package/src/tempo/session/precompile/Chain.ts +102 -8
  147. package/src/tempo/session/precompile/Channel.test.ts +9 -0
  148. package/src/tempo/session/precompile/Channel.ts +5 -0
  149. package/src/tempo/session/server/RequestState.test.ts +8 -1
  150. package/src/tempo/session/server/RequestState.ts +2 -2
  151. package/src/tempo/session/server/Session.test.ts +66 -4
  152. package/src/tempo/session/server/Ws.ts +3 -0
  153. package/src/tempo/subscription/KeyAuthorization.test.ts +70 -1
  154. package/src/tempo/subscription/KeyAuthorization.ts +14 -3
  155. package/src/viem/Client.test.ts +18 -1
  156. package/src/viem/Client.ts +7 -1
@@ -46,6 +46,20 @@ async function provisionAndPayTestnet(
46
46
  await Promise.all(hashes.map((hash) => waitForTransactionReceipt(client, { hash })))
47
47
  if (!silent) console.log(pc.dim(` Using wallet: ${account.address}`))
48
48
 
49
+ // The faucet tx receipt can land before the RPC's balance reads are
50
+ // consistent with it, so poll briefly for the funded balance to appear
51
+ // rather than racing straight into the charge.
52
+ const currency = (challenge.request as Record<string, unknown>).currency
53
+ if (typeof currency === 'string') {
54
+ for (let attempt = 0; attempt < 10; attempt++) {
55
+ const balance = await Actions.token
56
+ .getBalance(client, { account: account.address, token: currency as Address })
57
+ .catch(() => undefined)
58
+ if (balance && balance.amount > 0n) break
59
+ await new Promise((resolve) => setTimeout(resolve, 500))
60
+ }
61
+ }
62
+
49
63
  const methods = [...tempoMethods({ account })]
50
64
  return { methods }
51
65
  } catch (error) {
@@ -198,7 +212,7 @@ export async function validatePaymentFlow(
198
212
  const mppx = Mppx.create({ methods: provisioned.methods, polyfill: false })
199
213
  const cred = await mppx.createCredential(fakeResp)
200
214
  results.push(check('Payment: submitted', 'ephemeral testnet wallet'))
201
- return await sendAndValidateResponse(
215
+ await sendAndValidateResponse(
202
216
  results,
203
217
  url,
204
218
  endpoint,
@@ -210,17 +224,17 @@ export async function validatePaymentFlow(
210
224
  )
211
225
  } catch (error) {
212
226
  results.push(fail('Payment: create credential', (error as Error).message))
213
- return results
214
227
  }
215
228
  } else {
216
229
  results.push(
217
230
  fail('Payment: auto-provision wallet', 'Failed to create and fund testnet wallet'),
218
231
  )
219
- return results
220
232
  }
233
+ options.onResults?.(results)
221
234
  }
222
235
 
223
- // Try each challenge method (skip testnet challenge already handled above)
236
+ // Try each remaining challenge method (tempo testnet already handled above),
237
+ // so validate exercises every payment method the server offers, same as mainnet.
224
238
  const loaded = await loadConfig().catch(() => undefined)
225
239
  const isInteractive = options.interactive
226
240
  const stripeKey = process.env.MPPX_STRIPE_SECRET_KEY ?? resolveStripeKey(verbose)
@@ -707,6 +707,22 @@ describe('validate: multi-challenge', () => {
707
707
  const { output } = await serve(['validate', server.url])
708
708
  expect(output).toContain('Amount is valid integer string (Got: 1.50)')
709
709
  })
710
+
711
+ test("tempo doesn't block other testnet methods", { timeout: 15_000 }, async () => {
712
+ // makeChallenge() defaults to a tempo testnet challenge (chainId 42431),
713
+ // which takes the ephemeral-wallet fast path. That path used to return
714
+ // immediately, so a server offering evm/stripe alongside tempo would
715
+ // never have those methods exercised. It should now fall through and
716
+ // try them too, same as it does for tempo mainnet challenges.
717
+ const server = await multiChallengeServer([makeChallenge(), makeEvmChallenge()])
718
+ const { output } = await serve(['validate', server.url, '--outputJson', '--yes'])
719
+ const jsonStart = output.indexOf('{')
720
+ const jsonEnd = output.lastIndexOf('}')
721
+ const result = JSON.parse(output.slice(jsonStart, jsonEnd + 1))
722
+ const paymentLabels = result.endpoints[0].payment.map((r: { label: string }) => r.label)
723
+ expect(paymentLabels).toContain('Payment: submitted')
724
+ expect(paymentLabels.some((l: string) => l.includes('[evm]'))).toBe(true)
725
+ })
710
726
  })
711
727
 
712
728
  describe('validate: payment methods coverage', () => {
@@ -262,9 +262,9 @@ describe('fetch context', () => {
262
262
  type FetchInit = NonNullable<Parameters<typeof _mppx.fetch>[1]>
263
263
  type Context = NonNullable<FetchInit['context']>
264
264
 
265
- // Context is a union of charge and session contexts.
266
- // `account` exists on both; `autoSwap` only on charge.
265
+ // Context is a union of charge and session contexts; both support auto-swap.
267
266
  expectTypeOf<Context>().toHaveProperty('account')
268
- expectTypeOf<Extract<Context, { autoSwap?: unknown }>>().toHaveProperty('autoSwap')
267
+ expectTypeOf<Context>().toHaveProperty('autoSwap')
268
+ expectTypeOf<Context['autoSwap']>().toEqualTypeOf<AutoSwap.resolve.Value | undefined>()
269
269
  })
270
270
  })
@@ -10,6 +10,7 @@ import { rpcUrl } from '~test/tempo/rpc.js'
10
10
  import { accounts, asset, chain, client, http } from '~test/tempo/viem.js'
11
11
 
12
12
  import * as Fetch from './Fetch.js'
13
+ import * as MethodChallenge from './MethodChallenge.js'
13
14
  import * as MethodResponse from './MethodResponse.js'
14
15
 
15
16
  const realm = 'api.example.com'
@@ -337,6 +338,23 @@ const noopMethod = {
337
338
  createCredential: async () => 'credential',
338
339
  } as any
339
340
 
341
+ function trackedMethod(settlements: MethodResponse.AttemptOutcome['status'][]) {
342
+ const method = {
343
+ ...noopMethod,
344
+ async createCredential(parameters: object) {
345
+ const attempt = MethodResponse.getAttempt(parameters)
346
+ if (attempt)
347
+ attempt.settle = (outcome) => {
348
+ settlements.push(outcome.status)
349
+ return outcome.status !== 'pending'
350
+ }
351
+ return 'credential'
352
+ },
353
+ }
354
+ MethodChallenge.register(method, () => undefined)
355
+ return MethodResponse.register(method, ({ response }) => response)
356
+ }
357
+
340
358
  const x402PaymentRequired = {
341
359
  accepts: [
342
360
  {
@@ -418,6 +436,30 @@ describe('Fetch.from: method responses', () => {
418
436
  expect(handle).toHaveBeenCalledWith(expect.objectContaining({ credential: 'credential' }))
419
437
  expect(paidResponse?.clone).not.toHaveBeenCalled()
420
438
  })
439
+
440
+ test.each([
441
+ ['a rejected paid response', () => new Response('rejected', { status: 500 }), 500, undefined],
442
+ ['the final payment retry', () => make402(), 402, 1],
443
+ [
444
+ 'the paid request throwing',
445
+ () => Promise.reject(new Error('paid request failed')),
446
+ undefined,
447
+ undefined,
448
+ ],
449
+ ])('settles credential state after %s', async (_name, paidRequest, status, maxPaymentRetries) => {
450
+ const settlements: MethodResponse.AttemptOutcome['status'][] = []
451
+ let calls = 0
452
+ const fetch = Fetch.from({
453
+ fetch: async () => (++calls === 1 ? make402() : paidRequest()),
454
+ maxPaymentRetries,
455
+ methods: [trackedMethod(settlements)],
456
+ })
457
+
458
+ const result = fetch('https://example.com/paid')
459
+ if (status) expect((await result).status).toBe(status)
460
+ else await expect(result).rejects.toThrow('paid request failed')
461
+ expect(settlements).toEqual(['rejected'])
462
+ })
421
463
  })
422
464
 
423
465
  describe('Fetch.from: init passthrough (non-402)', () => {
@@ -1873,6 +1915,41 @@ describe('Fetch.from: 402 retry path', () => {
1873
1915
  ).toEqual(['credential-first', 'credential-second', 'credential-third'])
1874
1916
  })
1875
1917
 
1918
+ test('invalidates a prepared credential across a generic challenge', async () => {
1919
+ const firstCredential = vi.fn(async () => 'first')
1920
+ const first = { ...noopMethod, name: 'first', createCredential: firstCredential }
1921
+ const second = { ...noopMethod, name: 'second', createCredential: async () => 'second' }
1922
+ MethodChallenge.register(first, () => undefined)
1923
+ const responses = [
1924
+ make402({ id: 'first', method: 'first' }),
1925
+ make402({ id: 'second', method: 'second' }),
1926
+ make402({ id: 'first', method: 'first' }),
1927
+ new Response('OK'),
1928
+ ]
1929
+ const mockFetch = vi.fn(async () => responses.shift()!)
1930
+ const fetch = Fetch.from({ fetch: mockFetch, methods: [first, second] })
1931
+
1932
+ expect((await fetch('https://example.com/api')).status).toBe(200)
1933
+ expect(firstCredential).toHaveBeenCalledTimes(2)
1934
+ })
1935
+
1936
+ test('invalidates a prepared credential after an explicit context', async () => {
1937
+ const createCredential = vi.fn(async () => 'credential')
1938
+ const method = { ...noopMethod, createCredential }
1939
+ MethodChallenge.register(method, () => undefined)
1940
+ let challenges = 0
1941
+ const mockFetch = vi.fn(async () => (++challenges <= 3 ? make402() : new Response('OK')))
1942
+ const fetch = Fetch.from({
1943
+ fetch: mockFetch as typeof globalThis.fetch,
1944
+ methods: [method],
1945
+ onChallenge: async (_challenge, { createCredential: create }) =>
1946
+ challenges === 2 ? create({ override: true } as never) : undefined,
1947
+ })
1948
+
1949
+ expect((await fetch('https://example.com/api')).status).toBe(200)
1950
+ expect(createCredential).toHaveBeenCalledTimes(3)
1951
+ })
1952
+
1876
1953
  test('caps repeated 402 retries at three', async () => {
1877
1954
  let callCount = 0
1878
1955
  const createCredential = vi.fn(async () => 'credential')
@@ -6,6 +6,7 @@ import type { MaybePromise } from '../../internal/types.js'
6
6
  import type * as Method from '../../Method.js'
7
7
  import type * as z from '../../zod.js'
8
8
  import * as Transport from '../Transport.js'
9
+ import * as MethodChallenge from './MethodChallenge.js'
9
10
  import * as MethodResponse from './MethodResponse.js'
10
11
 
11
12
  // We tag wrappers with a global symbol so we can recognize wrappers created by mppx,
@@ -18,9 +19,33 @@ type WrappedFetch = typeof globalThis.fetch & {
18
19
  [MPPX_FETCH_WRAPPER]?: typeof globalThis.fetch
19
20
  }
20
21
 
22
+ /** Credential state retained between payment retries until its method settles it. */
23
+ type PreparedCredential = {
24
+ /** Method-owned preparation and settlement hooks. */
25
+ attempt: MethodResponse.Attempt
26
+ /** Serialized challenge used to recognize the same retry. */
27
+ key?: string | undefined
28
+ /** Method that prepared the credential. */
29
+ method: Method.AnyClient
30
+ /** Memoized credential reused for the same retry. */
31
+ promise: Promise<string>
32
+ }
33
+
21
34
  let originalFetch: typeof globalThis.fetch | undefined
22
35
  const eventObserverChecks = new WeakMap<object, (name: keyof ClientEventMap) => boolean>()
23
36
 
37
+ async function settleAttempt(
38
+ prepared: PreparedCredential | undefined,
39
+ outcome: MethodResponse.AttemptOutcome,
40
+ ): Promise<boolean> {
41
+ const settle = prepared?.attempt.settle
42
+ if (!settle) return outcome.status !== 'pending'
43
+ prepared.attempt.settle = undefined
44
+ const settled = await settle(outcome)
45
+ if (!settled) prepared.attempt.settle = settle
46
+ return settled
47
+ }
48
+
24
49
  export type ClientEventMap<
25
50
  methods extends readonly Method.AnyClient[] = readonly Method.AnyClient[],
26
51
  response = Response,
@@ -209,6 +234,7 @@ export function from<const methods extends readonly Method.AnyClient[]>(
209
234
  let challenge: Challenge.Challenge | undefined
210
235
  let challenges: readonly Challenge.Challenge[] | undefined
211
236
  let mi: methods[number] | undefined
237
+ let preparedCredential: PreparedCredential | undefined
212
238
 
213
239
  try {
214
240
  for (let retry = 0; retry < maxPaymentRetries; retry++) {
@@ -228,6 +254,11 @@ export function from<const methods extends readonly Method.AnyClient[]>(
228
254
  )
229
255
  const selected = orderedCandidates[0]
230
256
  if (!selected) {
257
+ await settleAttempt(preparedCredential, {
258
+ challenges,
259
+ response,
260
+ status: 'rejected',
261
+ })
231
262
  // A post-payment 402 with no actionable challenge (e.g. rejected credential)
232
263
  // is the server's final answer, not a new challenge round.
233
264
  if (retry > 0) return response
@@ -241,9 +272,60 @@ export function from<const methods extends readonly Method.AnyClient[]>(
241
272
  mi = selected.method
242
273
  if (challenge.expires) Expires.assert(challenge.expires, challenge.id)
243
274
 
275
+ const paymentInput = resolvePaymentRetryInput(
276
+ response,
277
+ initialRequest.input,
278
+ initialRequest.input,
279
+ )
280
+ const previousCredential = preparedCredential
281
+ const key = Challenge.serialize(selectedChallenge)
282
+ const sameAttempt =
283
+ previousCredential?.method === selected.method && previousCredential?.key === key
284
+ let canReusePrevious = false
285
+ if (previousCredential) {
286
+ const settled = await settleAttempt(previousCredential, {
287
+ challenges,
288
+ response,
289
+ status: sameAttempt ? 'pending' : 'rejected',
290
+ })
291
+ canReusePrevious = sameAttempt && !settled
292
+ }
293
+ preparedCredential = canReusePrevious ? previousCredential : undefined
244
294
  const createCredential = memoizeCreateCredential(
245
- (overrideContext?: AnyContextFor<methods>) =>
246
- resolveCredential(selectedChallenge, selected.method, overrideContext ?? context),
295
+ async (overrideContext?: AnyContextFor<methods>) => {
296
+ if (overrideContext === undefined && canReusePrevious && previousCredential) {
297
+ return previousCredential.promise
298
+ }
299
+ if (canReusePrevious && previousCredential) {
300
+ await settleAttempt(previousCredential, { status: 'rejected' })
301
+ preparedCredential = undefined
302
+ }
303
+
304
+ const credentialContext = overrideContext ?? context
305
+ if (!MethodChallenge.has(selected.method))
306
+ return resolveCredential(selectedChallenge, selected.method, credentialContext)
307
+ const attempt: MethodResponse.Attempt = {
308
+ prepare: () =>
309
+ MethodChallenge.handle(selected.method, {
310
+ challenge: selectedChallenge,
311
+ context: credentialContext,
312
+ fetch: baseFetch,
313
+ input: paymentInput,
314
+ }),
315
+ }
316
+ const promise = attempt
317
+ .prepare()
318
+ .then(() =>
319
+ resolveCredential(selectedChallenge, selected.method, credentialContext, attempt),
320
+ )
321
+ preparedCredential = {
322
+ attempt,
323
+ key: overrideContext === undefined ? key : undefined,
324
+ method: selected.method,
325
+ promise,
326
+ }
327
+ return promise
328
+ },
247
329
  )
248
330
  const eventCredential = await events.emit(
249
331
  'challenge.received',
@@ -265,6 +347,11 @@ export function from<const methods extends readonly Method.AnyClient[]>(
265
347
  })
266
348
  : undefined)
267
349
  const credential = onChallengeCredential ?? (await createCredential())
350
+ const prepared = preparedCredential
351
+ if (prepared && (await prepared.promise.catch(() => undefined)) !== credential) {
352
+ await settleAttempt(prepared, { status: 'rejected' })
353
+ preparedCredential = undefined
354
+ }
268
355
  validateCredentialHeaderValue(credential)
269
356
  await events.emit(
270
357
  'credential.created',
@@ -278,11 +365,6 @@ export function from<const methods extends readonly Method.AnyClient[]>(
278
365
  }),
279
366
  )
280
367
 
281
- const paymentInput = resolvePaymentRetryInput(
282
- response,
283
- initialRequest.input,
284
- initialRequest.input,
285
- )
286
368
  response = await baseFetch(
287
369
  paymentInput,
288
370
  transport.setCredential(
@@ -294,6 +376,17 @@ export function from<const methods extends readonly Method.AnyClient[]>(
294
376
  { challenge: selectedChallenge },
295
377
  ),
296
378
  )
379
+ const paymentRequired = await transport.isPaymentRequired(
380
+ response,
381
+ transportRequest as never,
382
+ )
383
+ if (!paymentRequired) {
384
+ await settleAttempt(prepared, {
385
+ response,
386
+ status: response.ok ? 'accepted' : 'rejected',
387
+ })
388
+ preparedCredential = undefined
389
+ }
297
390
  // Cloning an unobserved stream would tee and buffer it indefinitely.
298
391
  if (response.ok && hasEventObservers(events, 'payment.response'))
299
392
  await events.emit(
@@ -307,7 +400,7 @@ export function from<const methods extends readonly Method.AnyClient[]>(
307
400
  response,
308
401
  }),
309
402
  )
310
- if (!(await transport.isPaymentRequired(response, transportRequest as never))) {
403
+ if (!paymentRequired) {
311
404
  if (!response.ok) return response
312
405
  return MethodResponse.handle(selected.method, {
313
406
  challenge: selectedChallenge,
@@ -325,8 +418,24 @@ export function from<const methods extends readonly Method.AnyClient[]>(
325
418
  })
326
419
  }
327
420
  }
421
+ challenges = undefined
422
+ try {
423
+ challenges = transport.getChallenges
424
+ ? await transport.getChallenges(response, transportRequest as never)
425
+ : [await transport.getChallenge(response, transportRequest as never)]
426
+ } catch {}
427
+ await settleAttempt(preparedCredential, {
428
+ challenges,
429
+ response,
430
+ status: 'rejected',
431
+ })
328
432
  return response
329
433
  } catch (error) {
434
+ await settleAttempt(preparedCredential, {
435
+ challenges,
436
+ response,
437
+ status: 'rejected',
438
+ })
330
439
  await events.emit(
331
440
  'payment.failed',
332
441
  createPaymentFailedPayload({
@@ -875,11 +984,13 @@ async function resolveCredential(
875
984
  challenge: Challenge.Challenge,
876
985
  mi: Method.AnyClient,
877
986
  context: unknown,
987
+ attempt?: MethodResponse.Attempt | undefined,
878
988
  ): Promise<string> {
879
989
  const parsedContext = mi.context && context !== undefined ? mi.context.parse(context) : undefined
880
- return mi.createCredential(
881
- parsedContext !== undefined ? { challenge, context: parsedContext } : ({ challenge } as never),
882
- )
990
+ const parameters =
991
+ parsedContext !== undefined ? { challenge, context: parsedContext } : { challenge }
992
+ if (attempt) MethodResponse.attachAttempt(mi, parameters, attempt)
993
+ return mi.createCredential(parameters as never)
883
994
  }
884
995
 
885
996
  function resolvePaymentPreferences<methods extends readonly Method.AnyClient[]>(
@@ -0,0 +1,35 @@
1
+ import type * as Challenge from '../../Challenge.js'
2
+ import type { MaybePromise } from '../../internal/types.js'
3
+ import type * as Method from '../../Method.js'
4
+
5
+ const handlers = new WeakMap<Method.AnyClient, Handler>()
6
+
7
+ /** Inputs available before a client method creates a challenge credential. */
8
+ export type HandlerParameters = {
9
+ challenge: Challenge.Challenge
10
+ context?: unknown
11
+ fetch: typeof globalThis.fetch
12
+ input: RequestInfo | URL
13
+ }
14
+
15
+ /** Internal client-method challenge hook. */
16
+ export type Handler = (parameters: HandlerParameters) => MaybePromise<void>
17
+
18
+ /** Registers an internal challenge hook without changing the public method shape. */
19
+ export function register<const method extends Method.AnyClient>(
20
+ method: method,
21
+ handler: Handler,
22
+ ): method {
23
+ handlers.set(method, handler)
24
+ return method
25
+ }
26
+
27
+ /** Returns whether a method registered pre-credential work. */
28
+ export function has(method: Method.AnyClient): boolean {
29
+ return handlers.has(method)
30
+ }
31
+
32
+ /** Runs method-specific work before creating a challenge credential. */
33
+ export function handle(method: Method.AnyClient, parameters: HandlerParameters): Promise<void> {
34
+ return Promise.resolve(handlers.get(method)?.(parameters))
35
+ }
@@ -3,6 +3,20 @@ import type { MaybePromise } from '../../internal/types.js'
3
3
  import type * as Method from '../../Method.js'
4
4
 
5
5
  const handlers = new WeakMap<Method.AnyClient, Handler>()
6
+ const attempts = new WeakMap<object, Attempt>()
7
+
8
+ export type AttemptOutcome = {
9
+ challenges?: readonly Challenge.Challenge[] | undefined
10
+ response?: Response | undefined
11
+ status: 'accepted' | 'pending' | 'rejected'
12
+ }
13
+
14
+ export type Attempt = {
15
+ /** Re-runs method preparation when serialized state changed before signing. */
16
+ prepare: () => Promise<void>
17
+ /** Settles state created for one credential attempt. */
18
+ settle?: ((outcome: AttemptOutcome) => MaybePromise<boolean>) | undefined
19
+ }
6
20
 
7
21
  /** Inputs available when a client method handles a successful paid response. */
8
22
  export type HandlerParameters = {
@@ -33,6 +47,20 @@ export function unregister(method: Method.AnyClient): void {
33
47
  handlers.delete(method)
34
48
  }
35
49
 
50
+ /** Adds response lifecycle state to internal credential parameters. */
51
+ export function attachAttempt(
52
+ method: Method.AnyClient,
53
+ parameters: object,
54
+ attempt: Attempt,
55
+ ): void {
56
+ if (handlers.has(method)) attempts.set(parameters, attempt)
57
+ }
58
+
59
+ /** Reads response lifecycle state when Fetch owns this credential. */
60
+ export function getAttempt(parameters: object): Attempt | undefined {
61
+ return attempts.get(parameters)
62
+ }
63
+
36
64
  /** Lets the selected client method handle a successful paid response. */
37
65
  export function handle(method: Method.AnyClient, parameters: HandlerParameters): Promise<Response> {
38
66
  return Promise.resolve(handlers.get(method)?.(parameters) ?? parameters.response)
@@ -223,6 +223,8 @@ describe('Mppx type tests', () => {
223
223
  const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey })
224
224
 
225
225
  expectTypeOf(mppx.verifyCredential).toBeFunction()
226
+ expectTypeOf(mppx.broadcastCredential).toBeFunction()
227
+ expectTypeOf(mppx.validateCredential).toBeFunction()
226
228
  })
227
229
 
228
230
  test('server events receive typed method context', () => {