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
@@ -47,6 +47,8 @@ export const WebSocketReadyState = {
47
47
  /** Client-side close code used for payment protocol errors. */
48
48
  export const ClientWebSocketProtocolErrorCloseCode = 3008
49
49
 
50
+ const webSocketChallengeRefreshWindowMs = 5_000
51
+
50
52
  type EventType = 'close' | 'error' | 'message' | 'open'
51
53
  type ManagedEventMap = {
52
54
  close: { code: number; reason: string; type: 'close'; wasClean: boolean }
@@ -268,6 +270,8 @@ export type ApplyTopUpResultParameters = {
268
270
  challengeId?: string | undefined
269
271
  /** Current active machine state, used to preserve paid unit count when possible. */
270
272
  currentState: SessionState
273
+ /** Highest deposit known before the top-up. */
274
+ knownDeposit?: bigint | undefined
271
275
  /** Receipt returned by the top-up POST, when present. */
272
276
  receipt?: SessionReceipt | undefined
273
277
  /** Latest locally observed spend in raw units. */
@@ -362,26 +366,36 @@ export function resolveManualTopUp(parameters: ResolveManualTopUpParameters): Ma
362
366
  /**
363
367
  * Applies local deposit and state bookkeeping for a top-up response.
364
368
  *
365
- * `deposit` is updated from the top-up amount. Receipt cumulative values only
366
- * update accepted spend authorization; they do not replace deposit.
369
+ * Deposit advances from the highest known baseline. Receipt cumulative values
370
+ * only update accepted spend authorization; they do not replace deposit.
367
371
  */
368
372
  export function applyTopUpResult(
369
373
  parameters: ApplyTopUpResultParameters,
370
374
  ): AppliedTopUpResult | undefined {
371
- const { additionalDeposit, channel, channelId, challengeId, currentState, receipt, spent } =
372
- parameters
375
+ const {
376
+ additionalDeposit,
377
+ channel,
378
+ channelId,
379
+ challengeId,
380
+ currentState,
381
+ knownDeposit,
382
+ receipt,
383
+ spent,
384
+ } = parameters
373
385
  if (channel?.channelId !== channelId) return undefined
374
386
 
375
- channel.deposit += additionalDeposit
387
+ const baseline =
388
+ knownDeposit !== undefined && knownDeposit > channel.deposit ? knownDeposit : channel.deposit
389
+ const updated = { ...channel, deposit: baseline + additionalDeposit }
376
390
 
377
- if (receipt) return { channel, state: activeStateFromReceipt(receipt, channel) }
378
- if (!challengeId) return { channel }
391
+ if (receipt) return { channel: updated, state: activeStateFromReceipt(receipt, updated) }
392
+ if (!challengeId) return { channel: updated }
379
393
 
380
394
  return {
381
- channel,
395
+ channel: updated,
382
396
  state: activeStateFromChannel({
383
397
  challengeId,
384
- entry: channel,
398
+ entry: updated,
385
399
  spent: spent.toString(),
386
400
  units: currentState.status === 'active' ? currentState.units : 0,
387
401
  }),
@@ -477,7 +491,7 @@ export type RetryHttpPaymentRequiredParameters = {
477
491
  /** Failed HTTP response that may contain a session challenge. */
478
492
  response: Response
479
493
  /** Restores local cumulative authorization if the voucher retry fails. */
480
- restoreCumulative(channelId: Hex.Hex, cumulativeAmount: bigint): void
494
+ restoreCumulative(channelId: Hex.Hex, cumulativeAmount: bigint): void | Promise<void>
481
495
  /** Stores the selected follow-up challenge on the manager. */
482
496
  setChallenge(challenge: TempoSessionChallenge): void
483
497
  /** Performs automatic top-up before the voucher retry when deposit is insufficient. */
@@ -521,6 +535,19 @@ export function isTempoSessionChallenge(
521
535
  )
522
536
  }
523
537
 
538
+ /** Returns true when a challenge selects the TIP-1034 session protocol. */
539
+ export function isTip1034SessionChallenge(
540
+ challenge: Challenge.Challenge,
541
+ ): challenge is TempoSessionChallenge {
542
+ return (
543
+ isTempoSessionChallenge(challenge) &&
544
+ Constants.getMethodDetail(
545
+ challenge.request.methodDetails,
546
+ Constants.MethodDetailKeys.sessionProtocol,
547
+ ) === Constants.SessionProtocols.v2
548
+ )
549
+ }
550
+
524
551
  /** Reads a server-provided session snapshot from challenge method details. */
525
552
  export function getSessionSnapshot(challenge: TempoSessionChallenge): SessionSnapshot | undefined {
526
553
  return Constants.getMethodDetail<SessionSnapshot>(
@@ -594,15 +621,17 @@ export async function postTopUp(
594
621
  throw new Error('Cannot top up session: no local channel descriptor available.')
595
622
  }
596
623
 
597
- const credential = await parameters.createSessionCredential(parameters.challenge, {
598
- action: 'topUp',
599
- channelId,
600
- descriptor: channel.descriptor,
601
- additionalDepositRaw: parameters.additionalDeposit.toString(),
602
- })
603
- const response = await parameters.fetch(managementInput(parameters.input), {
604
- method: 'POST',
605
- headers: { [Constants.Headers.authorization]: credential },
624
+ const response = await postManagementCredential({
625
+ challenge: parameters.challenge,
626
+ context: {
627
+ action: 'topUp',
628
+ channelId,
629
+ descriptor: channel.descriptor,
630
+ additionalDepositRaw: parameters.additionalDeposit.toString(),
631
+ },
632
+ createSessionCredential: parameters.createSessionCredential,
633
+ fetch: parameters.fetch,
634
+ input: parameters.input,
606
635
  })
607
636
  if (!response.ok) throw new Error(`Top-up POST failed with status ${response.status}`)
608
637
 
@@ -610,6 +639,30 @@ export async function postTopUp(
610
639
  return receiptHeader ? deserializeSessionReceipt(receiptHeader) : undefined
611
640
  }
612
641
 
642
+ /** Posts a management credential and retries once with a refreshed TIP-1034 challenge. */
643
+ async function postManagementCredential(parameters: {
644
+ challenge: TempoSessionChallenge
645
+ context: SessionContext
646
+ createSessionCredential: CreateSessionCredential
647
+ fetch: typeof globalThis.fetch
648
+ input: RequestInfo | URL
649
+ }): Promise<Response> {
650
+ const post = async (challenge: TempoSessionChallenge) =>
651
+ parameters.fetch(managementInput(parameters.input), {
652
+ method: 'POST',
653
+ headers: {
654
+ [Constants.Headers.authorization]: await parameters.createSessionCredential(
655
+ challenge,
656
+ parameters.context,
657
+ ),
658
+ },
659
+ })
660
+ const response = await post(parameters.challenge)
661
+ if (response.status !== 402) return response
662
+ const challenge = Challenge.fromResponseList(response).find(isTip1034SessionChallenge)
663
+ return challenge ? post(challenge) : response
664
+ }
665
+
613
666
  /** Retries an HTTP 402 when the server snapshot asks for more voucher headroom. */
614
667
  export async function retryHttpPaymentRequired(
615
668
  parameters: RetryHttpPaymentRequiredParameters,
@@ -641,18 +694,25 @@ export async function retryHttpPaymentRequired(
641
694
  currentChannel.cumulativeAmount > requiredCumulative
642
695
  ? currentChannel.cumulativeAmount
643
696
  : requiredCumulative
644
- const credential = await parameters.createSessionCredential(challenge, {
645
- action: 'voucher',
646
- channelId: snapshot.channelId,
647
- descriptor: currentChannel.descriptor,
648
- cumulativeAmountRaw: cumulativeAmount.toString(),
649
- })
650
- const retry = await parameters.fetch(
651
- parameters.input,
652
- requestInitWithAuthorization(parameters.input, parameters.init, credential),
653
- )
697
+ const restore = () => parameters.restoreCumulative(snapshot.channelId, cumulativeBeforeVoucher)
698
+ let retry: Response
699
+ try {
700
+ const credential = await parameters.createSessionCredential(challenge, {
701
+ action: 'voucher',
702
+ channelId: snapshot.channelId,
703
+ descriptor: currentChannel.descriptor,
704
+ cumulativeAmountRaw: cumulativeAmount.toString(),
705
+ })
706
+ retry = await parameters.fetch(
707
+ parameters.input,
708
+ requestInitWithAuthorization(parameters.input, parameters.init, credential),
709
+ )
710
+ } catch (error) {
711
+ await restore()
712
+ throw error
713
+ }
654
714
  if (!retry.ok && !retry.headers.get(Constants.Headers.paymentReceipt)) {
655
- parameters.restoreCumulative(snapshot.channelId, cumulativeBeforeVoucher)
715
+ await restore()
656
716
  }
657
717
  return retry
658
718
  }
@@ -662,7 +722,7 @@ export function resolveRetryHttpPaymentContext(parameters: {
662
722
  channel: ChannelEntry | null
663
723
  response: Response
664
724
  }): RetryHttpPaymentContext | undefined {
665
- const challenge = Challenge.fromResponseList(parameters.response).find(isTempoSessionChallenge)
725
+ const challenge = Challenge.fromResponseList(parameters.response).find(isTip1034SessionChallenge)
666
726
  if (!challenge) return undefined
667
727
 
668
728
  const snapshot = getSessionSnapshot(challenge)
@@ -705,7 +765,7 @@ export async function closeHttpSession(
705
765
 
706
766
  let response = await postClose(parameters.target.challenge)
707
767
  if (response.status === 402) {
708
- const challenge = Challenge.fromResponseList(response).find(isTempoSessionChallenge)
768
+ const challenge = Challenge.fromResponseList(response).find(isTip1034SessionChallenge)
709
769
  if (challenge) {
710
770
  parameters.setChallenge?.(challenge)
711
771
  response = await postClose(challenge, true)
@@ -768,8 +828,6 @@ export type OpenSseSessionParameters = {
768
828
  getChallenge(): TempoSessionChallenge | null
769
829
  /** Validates a cumulative amount against local client policy. */
770
830
  assertVoucherWithinLocalLimit(cumulativeAmount: bigint): void
771
- /** Converts a resource URL to the server's session management URL. */
772
- managementInput(input: RequestInfo | URL): RequestInfo | URL
773
831
  /** Applies an incoming receipt to manager state. */
774
832
  acceptReceipt(receipt: SessionReceipt): void
775
833
  /** Performs an automatic channel top-up when deposit is insufficient. */
@@ -783,7 +841,6 @@ export type SsePaymentDriver = Pick<
783
841
  | 'createSessionCredential'
784
842
  | 'fetch'
785
843
  | 'getChannel'
786
- | 'managementInput'
787
844
  | 'topUpIfNeeded'
788
845
  >
789
846
 
@@ -1018,17 +1075,13 @@ export async function handleSseNeedVoucher(
1018
1075
  })
1019
1076
  if (resolution.status !== 'ready') return
1020
1077
 
1021
- const credential = await parameters.driver.createSessionCredential(
1022
- parameters.challenge,
1023
- resolution.context,
1024
- )
1025
- const voucherResponse = await parameters.driver.fetch(
1026
- parameters.driver.managementInput(parameters.input),
1027
- {
1028
- method: 'POST',
1029
- headers: { [Constants.Headers.authorization]: credential },
1030
- },
1031
- )
1078
+ const voucherResponse = await postManagementCredential({
1079
+ challenge: parameters.challenge,
1080
+ context: resolution.context,
1081
+ createSessionCredential: parameters.driver.createSessionCredential,
1082
+ fetch: parameters.driver.fetch,
1083
+ input: parameters.input,
1084
+ })
1032
1085
  if (!voucherResponse.ok) {
1033
1086
  throw new Error(`Voucher POST failed with status ${voucherResponse.status}`)
1034
1087
  }
@@ -1066,6 +1119,15 @@ export type PrepareWebSocketSessionParameters = {
1066
1119
  signal?: AbortSignal | undefined
1067
1120
  }
1068
1121
 
1122
+ /** Inputs for probing a paid WebSocket endpoint without creating a credential. */
1123
+ export type ProbeWebSocketSessionParameters = Omit<
1124
+ PrepareWebSocketSessionParameters,
1125
+ 'createSessionCredential'
1126
+ >
1127
+
1128
+ /** Result of resolving the current payment challenge for a WebSocket endpoint. */
1129
+ export type ProbedWebSocketSession = Omit<PreparedWebSocketSession, 'credential'>
1130
+
1069
1131
  /** Result of the HTTP probe and opening credential creation for a WebSocket session. */
1070
1132
  export type PreparedWebSocketSession = {
1071
1133
  /** Selected tempo/session challenge from the HTTP probe. */
@@ -1129,6 +1191,8 @@ export type OpenWebSocketSessionParameters = {
1129
1191
  getChannel(): ChannelEntry | null
1130
1192
  /** Stores the active socket state in the manager. */
1131
1193
  setSocketSession(session: ActiveSocketSession): void
1194
+ /** Fetches the route's current challenge after the active one expires. */
1195
+ refreshChallenge(): Promise<TempoSessionChallenge>
1132
1196
  /** Validates a cumulative amount against local client policy. */
1133
1197
  assertVoucherWithinLocalLimit(cumulativeAmount: bigint): void
1134
1198
  /** Applies an incoming receipt to manager state. */
@@ -1169,10 +1233,10 @@ export type ValidateSocketPaymentReceiptParameters = ExpectedSocketReceiptParame
1169
1233
  expectedCloseAmount: string | null
1170
1234
  }
1171
1235
 
1172
- /** Probes a WebSocket endpoint over HTTP and creates the opening credential. */
1173
- export async function prepareWebSocketSession(
1174
- parameters: PrepareWebSocketSessionParameters,
1175
- ): Promise<PreparedWebSocketSession> {
1236
+ /** Probes a WebSocket endpoint over HTTP and returns its current session challenge. */
1237
+ export async function probeWebSocketSession(
1238
+ parameters: ProbeWebSocketSessionParameters,
1239
+ ): Promise<ProbedWebSocketSession> {
1176
1240
  const wsUrl = new URL(parameters.input.toString())
1177
1241
  const httpUrl = webSocketProbeUrl(wsUrl)
1178
1242
  parameters.onProbeUrl?.(httpUrl)
@@ -1186,7 +1250,7 @@ export async function prepareWebSocketSession(
1186
1250
  )
1187
1251
  }
1188
1252
 
1189
- const challenge = Challenge.fromResponseList(probe).find(isTempoSessionChallenge)
1253
+ const challenge = Challenge.fromResponseList(probe).find(isTip1034SessionChallenge)
1190
1254
  if (!challenge) {
1191
1255
  throw new Error(
1192
1256
  'No payment challenge received from HTTP endpoint for this WebSocket URL. The server may not require payment or did not advertise a challenge.',
@@ -1195,12 +1259,22 @@ export async function prepareWebSocketSession(
1195
1259
 
1196
1260
  return {
1197
1261
  challenge,
1198
- credential: await parameters.createSessionCredential(challenge, {}),
1199
1262
  httpUrl,
1200
1263
  wsUrl,
1201
1264
  }
1202
1265
  }
1203
1266
 
1267
+ /** Probes a WebSocket endpoint over HTTP and creates the opening credential. */
1268
+ export async function prepareWebSocketSession(
1269
+ parameters: PrepareWebSocketSessionParameters,
1270
+ ): Promise<PreparedWebSocketSession> {
1271
+ const probed = await probeWebSocketSession(parameters)
1272
+ return {
1273
+ ...probed,
1274
+ credential: await parameters.createSessionCredential(probed.challenge, {}),
1275
+ }
1276
+ }
1277
+
1204
1278
  /** Creates the initial runtime state for a paid WebSocket from its opening credential. */
1205
1279
  export function createActiveSocketSession(
1206
1280
  parameters: CreateActiveSocketSessionParameters,
@@ -1408,9 +1482,18 @@ async function handleNeedVoucher(
1408
1482
  event: NeedVoucherEvent,
1409
1483
  ) {
1410
1484
  try {
1485
+ let challenge = parameters.driver.challenge
1486
+ if (
1487
+ challenge.expires &&
1488
+ new Date(challenge.expires).getTime() <= Date.now() + webSocketChallengeRefreshWindowMs
1489
+ ) {
1490
+ challenge = await parameters.driver.refreshChallenge()
1491
+ parameters.driver.challenge = challenge
1492
+ parameters.socketState.challenge = challenge
1493
+ }
1411
1494
  const resolution = await resolveNeedVoucherContext({
1412
1495
  assertVoucherWithinLocalLimit: parameters.driver.assertVoucherWithinLocalLimit,
1413
- challenge: parameters.driver.challenge,
1496
+ challenge,
1414
1497
  event,
1415
1498
  expectedChannelId: parameters.socketState.channelId,
1416
1499
  getChannel: parameters.driver.getChannel,
@@ -1425,10 +1508,7 @@ async function handleNeedVoucher(
1425
1508
  )
1426
1509
  return
1427
1510
  }
1428
- const voucher = await parameters.driver.createSessionCredential(
1429
- parameters.driver.challenge,
1430
- resolution.context,
1431
- )
1511
+ const voucher = await parameters.driver.createSessionCredential(challenge, resolution.context)
1432
1512
  parameters.rawSocket.send(Ws.formatAuthorizationMessage(voucher))
1433
1513
  } catch (error) {
1434
1514
  parameters.failSocketFlow(
@@ -1,5 +1,12 @@
1
1
  import { Hex } from 'ox'
2
- import { type Address, encodeFunctionData, isAddressEqual, parseEventLogs, zeroAddress } from 'viem'
2
+ import {
3
+ type Address,
4
+ encodeFunctionData,
5
+ isAddressEqual,
6
+ maxUint256,
7
+ parseEventLogs,
8
+ zeroAddress,
9
+ } from 'viem'
3
10
  import { sendTransaction, waitForTransactionReceipt } from 'viem/actions'
4
11
  import { Transaction } from 'viem/tempo'
5
12
  import { describe, expect, test } from 'vp/test'
@@ -42,6 +49,15 @@ function getSingleEvent(receipt: { logs: readonly unknown[] }, name: string) {
42
49
  return logs[0] as unknown as { args: Record<string, unknown> }
43
50
  }
44
51
 
52
+ function expectExpiringNonce(serializedTransaction: Hex.Hex) {
53
+ const transaction = Transaction.deserialize(
54
+ serializedTransaction as Transaction.TransactionSerializedTempo,
55
+ ) as unknown as { nonce: number; nonceKey: bigint; validBefore?: bigint }
56
+ expect(transaction.nonce).toBe(0)
57
+ expect(transaction.nonceKey).toBe(maxUint256)
58
+ expect(transaction.validBefore).toBeDefined()
59
+ }
60
+
45
61
  async function openChannel(parameters: { deposit?: bigint | undefined } = {}) {
46
62
  const deposit = uint96(parameters.deposit ?? 1_000n)
47
63
  const salt = Hex.random(32)
@@ -120,6 +136,7 @@ describe.runIf(isPrecompileTestnet)('TIP20EscrowChannel precompile chain operati
120
136
  token: asset,
121
137
  })
122
138
  if (payload.action !== 'open') throw new Error('expected open payload')
139
+ expectExpiringNonce(payload.transaction)
123
140
 
124
141
  const transaction = Transaction.deserialize(
125
142
  payload.transaction as Transaction.TransactionSerializedTempo,
@@ -224,6 +241,7 @@ describe.runIf(isPrecompileTestnet)('TIP20EscrowChannel precompile chain operati
224
241
  chain.id,
225
242
  )
226
243
  if (topUp.action !== 'topUp') throw new Error('expected topUp payload')
244
+ expectExpiringNonce(topUp.transaction)
227
245
 
228
246
  const result = await Chain.broadcastTopUpTransaction({
229
247
  additionalDeposit,
@@ -12,7 +12,7 @@ import {
12
12
  type Hex,
13
13
  } from 'viem'
14
14
  import { privateKeyToAccount } from 'viem/accounts'
15
- import { Transaction } from 'viem/tempo'
15
+ import { Abis, Addresses, Transaction } from 'viem/tempo'
16
16
  import { describe, expect, test } from 'vp/test'
17
17
 
18
18
  import { VerificationFailedError } from '../../../Errors.js'
@@ -36,6 +36,7 @@ const descriptor = {
36
36
  const deposit = Types.uint96(1_000_000n)
37
37
  const chainId = 42431
38
38
  const txHash = `0x${'ab'.repeat(32)}` as const
39
+ const sourceToken = '0x6666666666666666666666666666666666666666' as const
39
40
  const feePayer = privateKeyToAccount(
40
41
  '0x59c6995e998f97a5a0044966f0945389d1fc6e60e7346d6c36c49d32f75b9a1b',
41
42
  )
@@ -162,7 +163,11 @@ function topUpLog(parameters: { channelId: `0x${string}`; newDeposit: bigint })
162
163
  }
163
164
 
164
165
  async function createSerializedTransaction(parameters: {
165
- calls: { to: `0x${string}`; data?: `0x${string}` | undefined }[]
166
+ calls: {
167
+ to?: `0x${string}` | undefined
168
+ data?: `0x${string}` | undefined
169
+ value?: bigint | undefined
170
+ }[]
166
171
  gas?: bigint | undefined
167
172
  signed?: boolean | undefined
168
173
  }) {
@@ -192,6 +197,28 @@ async function createSerializedTransaction(parameters: {
192
197
  } as never)) as `0x${string}`
193
198
  }
194
199
 
200
+ function autoSwapCalls(amountOut: bigint = deposit) {
201
+ const maxAmountIn = amountOut + 1_000n
202
+ return [
203
+ {
204
+ to: sourceToken,
205
+ data: encodeFunctionData({
206
+ abi: Abis.tip20,
207
+ functionName: 'approve',
208
+ args: [Addresses.stablecoinDex, maxAmountIn],
209
+ }),
210
+ },
211
+ {
212
+ to: Addresses.stablecoinDex,
213
+ data: encodeFunctionData({
214
+ abi: Abis.stablecoinDex,
215
+ functionName: 'swapExactAmountOut',
216
+ args: [sourceToken, descriptor.token, amountOut, maxAmountIn],
217
+ }),
218
+ },
219
+ ] as const
220
+ }
221
+
195
222
  async function createOpenTransaction(
196
223
  parameters: {
197
224
  authorizedSigner?: `0x${string}` | undefined
@@ -201,6 +228,13 @@ async function createOpenTransaction(
201
228
  signed?: boolean | undefined
202
229
  token?: `0x${string}` | undefined
203
230
  to?: `0x${string}` | undefined
231
+ prefixCalls?:
232
+ | readonly {
233
+ to?: `0x${string}` | undefined
234
+ data?: `0x${string}` | undefined
235
+ value?: bigint | undefined
236
+ }[]
237
+ | undefined
204
238
  } = {},
205
239
  ) {
206
240
  const data = encodeFunctionData({
@@ -216,7 +250,7 @@ async function createOpenTransaction(
216
250
  ],
217
251
  })
218
252
  return createSerializedTransaction({
219
- calls: [{ to: parameters.to ?? tip20ChannelEscrow, data }],
253
+ calls: [...(parameters.prefixCalls ?? []), { to: parameters.to ?? tip20ChannelEscrow, data }],
220
254
  gas: parameters.gas,
221
255
  signed: parameters.signed,
222
256
  })
@@ -229,6 +263,13 @@ async function createTopUpTransaction(
229
263
  gas?: bigint | undefined
230
264
  signed?: boolean | undefined
231
265
  to?: `0x${string}` | undefined
266
+ prefixCalls?:
267
+ | readonly {
268
+ to?: `0x${string}` | undefined
269
+ data?: `0x${string}` | undefined
270
+ value?: bigint | undefined
271
+ }[]
272
+ | undefined
232
273
  } = {},
233
274
  ) {
234
275
  const data = encodeFunctionData({
@@ -240,7 +281,7 @@ async function createTopUpTransaction(
240
281
  ],
241
282
  })
242
283
  return createSerializedTransaction({
243
- calls: [{ to: parameters.to ?? tip20ChannelEscrow, data }],
284
+ calls: [...(parameters.prefixCalls ?? []), { to: parameters.to ?? tip20ChannelEscrow, data }],
244
285
  gas: parameters.gas,
245
286
  signed: parameters.signed,
246
287
  })
@@ -446,8 +487,152 @@ describe('precompile broadcastOpenTransaction', () => {
446
487
  expectedPayer: descriptor.payer,
447
488
  serializedTransaction,
448
489
  }),
449
- ).rejects.toThrow('TIP-1034 open transaction must contain exactly one call')
490
+ ).rejects.toThrow(
491
+ 'TIP-1034 open transaction must contain one management call, optionally preceded by an auto-swap',
492
+ )
493
+ })
494
+
495
+ const [validApprove, validSwap] = autoSwapCalls()
496
+ const wrongToken = '0x7777777777777777777777777777777777777777' as const
497
+ const maxAmountIn = deposit + 1_000n
498
+ const approve = (parameters: {
499
+ amount?: bigint | undefined
500
+ spender?: Address | undefined
501
+ token?: Address | undefined
502
+ }) => ({
503
+ to: parameters.token ?? sourceToken,
504
+ data: encodeFunctionData({
505
+ abi: Abis.tip20,
506
+ functionName: 'approve',
507
+ args: [parameters.spender ?? Addresses.stablecoinDex, parameters.amount ?? maxAmountIn],
508
+ }),
509
+ })
510
+ const swap = (parameters: {
511
+ amountOut?: bigint | undefined
512
+ maxAmount?: bigint | undefined
513
+ tokenIn?: Address | undefined
514
+ tokenOut?: Address | undefined
515
+ }) => ({
516
+ to: Addresses.stablecoinDex as Address,
517
+ data: encodeFunctionData({
518
+ abi: Abis.stablecoinDex,
519
+ functionName: 'swapExactAmountOut',
520
+ args: [
521
+ parameters.tokenIn ?? sourceToken,
522
+ parameters.tokenOut ?? descriptor.token,
523
+ parameters.amountOut ?? deposit,
524
+ parameters.maxAmount ?? maxAmountIn,
525
+ ],
526
+ }),
450
527
  })
528
+ const invalidAutoSwapPrefixes = [
529
+ {
530
+ name: 'missing call target',
531
+ prefixCalls: [{ data: validApprove.data }, validSwap],
532
+ reason: 'call is missing a target or calldata',
533
+ },
534
+ {
535
+ name: 'native value',
536
+ prefixCalls: [{ ...validApprove, value: 1n }, validSwap],
537
+ reason: 'calls must not transfer native value',
538
+ },
539
+ {
540
+ name: 'wrong DEX target',
541
+ prefixCalls: [validApprove, { ...validSwap, to: wrongToken }],
542
+ reason: 'targets the wrong DEX',
543
+ },
544
+ {
545
+ name: 'invalid approval calldata',
546
+ prefixCalls: [{ ...validApprove, data: '0x12345678' as const }, validSwap],
547
+ reason: 'approval calldata is invalid',
548
+ },
549
+ {
550
+ name: 'invalid swap calldata',
551
+ prefixCalls: [validApprove, { ...validSwap, data: '0x12345678' as const }],
552
+ reason: 'swap calldata is invalid',
553
+ },
554
+ {
555
+ name: 'wrong approval function',
556
+ prefixCalls: [
557
+ {
558
+ to: sourceToken,
559
+ data: encodeFunctionData({
560
+ abi: Abis.tip20,
561
+ functionName: 'transfer',
562
+ args: [Addresses.stablecoinDex, maxAmountIn],
563
+ }),
564
+ },
565
+ validSwap,
566
+ ],
567
+ reason: 'must contain approve followed by swapExactAmountOut',
568
+ },
569
+ {
570
+ name: 'approval token mismatch',
571
+ prefixCalls: [approve({ token: wrongToken }), validSwap],
572
+ reason: 'approval token does not match swap input',
573
+ },
574
+ {
575
+ name: 'approval spender mismatch',
576
+ prefixCalls: [approve({ spender: wrongToken }), validSwap],
577
+ reason: 'approval spender is not the DEX',
578
+ },
579
+ {
580
+ name: 'approval amount mismatch',
581
+ prefixCalls: [approve({ amount: maxAmountIn + 1n }), validSwap],
582
+ reason: 'approval amount does not match swap maximum input',
583
+ },
584
+ {
585
+ name: 'output token mismatch',
586
+ prefixCalls: [validApprove, swap({ tokenOut: wrongToken })],
587
+ reason: 'output token does not match channel currency',
588
+ },
589
+ {
590
+ name: 'output amount mismatch',
591
+ prefixCalls: [
592
+ approve({ amount: maxAmountIn + 1n }),
593
+ swap({ amountOut: deposit + 1n, maxAmount: maxAmountIn + 1n }),
594
+ ],
595
+ reason: 'output amount does not match channel deposit',
596
+ },
597
+ {
598
+ name: 'identical input and output tokens',
599
+ prefixCalls: [approve({ token: descriptor.token }), swap({ tokenIn: descriptor.token })],
600
+ reason: 'input and output tokens must differ',
601
+ },
602
+ {
603
+ name: 'non-canonical approval calldata',
604
+ prefixCalls: [{ ...validApprove, data: `${validApprove.data}00` as Hex }, validSwap],
605
+ reason: 'approval calldata is not canonical',
606
+ },
607
+ {
608
+ name: 'non-canonical swap calldata',
609
+ prefixCalls: [validApprove, { ...validSwap, data: `${validSwap.data}00` as Hex }],
610
+ reason: 'swap calldata is not canonical',
611
+ },
612
+ ] as const
613
+
614
+ test.each(invalidAutoSwapPrefixes)(
615
+ 'rejects an auto-swap with $name',
616
+ async ({ prefixCalls, reason }) => {
617
+ const serializedTransaction = await createOpenTransaction({ prefixCalls })
618
+
619
+ await expect(
620
+ Chain.broadcastOpenTransaction({
621
+ chainId,
622
+ client: createMockClient(),
623
+ escrowContract: tip20ChannelEscrow,
624
+ expectedAuthorizedSigner: descriptor.authorizedSigner,
625
+ expectedChannelId: `0x${'11'.repeat(32)}`,
626
+ expectedCurrency: descriptor.token,
627
+ expectedExpiringNonceHash: expectedExpiringNonceHash(serializedTransaction),
628
+ expectedOperator: descriptor.operator,
629
+ expectedPayee: descriptor.payee,
630
+ expectedPayer: descriptor.payer,
631
+ serializedTransaction,
632
+ }),
633
+ ).rejects.toThrow(`TIP-1034 open auto-swap ${reason}`)
634
+ },
635
+ )
451
636
 
452
637
  test('rejects open transactions targeting the wrong escrow contract', async () => {
453
638
  const serializedTransaction = await createOpenTransaction({ to: descriptor.token })
@@ -715,8 +900,8 @@ describe('precompile broadcastOpenTransaction', () => {
715
900
  ).rejects.toThrow('credential expiringNonceHash does not match transaction')
716
901
  })
717
902
 
718
- test('returns tx hash, descriptor, event fields, and read-back state on success', async () => {
719
- const serializedTransaction = await createOpenTransaction()
903
+ test('accepts an exact-output auto-swap before open', async () => {
904
+ const serializedTransaction = await createOpenTransaction({ prefixCalls: autoSwapCalls() })
720
905
  const expiringNonceHash = expectedExpiringNonceHash(serializedTransaction)
721
906
  const expectedDescriptor = { ...descriptor, expiringNonceHash }
722
907
  const channelId = Channel.computeId({
@@ -858,7 +1043,9 @@ describe('precompile broadcastTopUpTransaction', () => {
858
1043
  expectedCurrency: descriptor.token,
859
1044
  serializedTransaction,
860
1045
  }),
861
- ).rejects.toThrow('TIP-1034 topUp transaction must contain exactly one call')
1046
+ ).rejects.toThrow(
1047
+ 'TIP-1034 topUp transaction must contain one management call, optionally preceded by an auto-swap',
1048
+ )
862
1049
  })
863
1050
 
864
1051
  test('rejects top-up transactions targeting the wrong escrow contract', async () => {
@@ -1025,8 +1212,8 @@ describe('precompile broadcastTopUpTransaction', () => {
1025
1212
  ).rejects.toThrow('topUp deposit does not match credential')
1026
1213
  })
1027
1214
 
1028
- test('returns tx hash, new deposit, and read-back state on success', async () => {
1029
- const serializedTransaction = await createTopUpTransaction()
1215
+ test('accepts an exact-output auto-swap before top-up', async () => {
1216
+ const serializedTransaction = await createTopUpTransaction({ prefixCalls: autoSwapCalls() })
1030
1217
  const channelId = Channel.computeId({ ...descriptor, chainId, escrow: tip20ChannelEscrow })
1031
1218
  const newDeposit = deposit * 2n
1032
1219
  const state = { settled: 0n, deposit: newDeposit, closeRequestedAt: 0 }