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
@@ -194,10 +194,25 @@ describe('precompile client ChannelOps credential builders', () => {
194
194
  const now = Math.floor(Date.now() / 1_000)
195
195
 
196
196
  expect(requests).toMatchObject([
197
- { feePayer: true, validAfter: expect.any(Number) },
198
- { feePayer: true, validAfter: expect.any(Number) },
197
+ { feePayer: true, nonceKey: 'expiring', validAfter: expect.any(Number) },
198
+ { feePayer: true, nonceKey: 'expiring', validAfter: expect.any(Number) },
199
199
  ])
200
200
  expect(new Set(validAfter).size).toBe(2)
201
201
  expect(validAfter.every((value) => value >= 0 && value < now)).toBe(true)
202
202
  })
203
+
204
+ test('uses expiring nonces and entropy for unsponsored management transactions', async () => {
205
+ mocks.prepareTransactionRequest.mockClear()
206
+
207
+ await ChannelOps.createTopUpPayload(client, account, descriptor, 10n, chainId, false)
208
+
209
+ expect(mocks.prepareTransactionRequest).toHaveBeenCalledWith(
210
+ client,
211
+ expect.objectContaining({
212
+ nonceKey: 'expiring',
213
+ validAfter: expect.any(Number),
214
+ }),
215
+ )
216
+ expect(mocks.prepareTransactionRequest.mock.calls[0]?.[1]).not.toHaveProperty('feePayer')
217
+ })
203
218
  })
@@ -28,7 +28,7 @@ import type {
28
28
  import { uint96 } from '../precompile/Protocol.js'
29
29
  import * as Voucher from '../precompile/Voucher.js'
30
30
 
31
- type TempoChannelCall = {
31
+ export type TempoChannelCall = {
32
32
  to: Address
33
33
  data: Hex.Hex
34
34
  }
@@ -91,14 +91,18 @@ async function prepareTempoChannelTransaction(
91
91
  },
92
92
  ) {
93
93
  const { account, calls, feePayer, feeToken, validAfter } = parameters
94
+ // Session management transactions are independent and short-lived, so they
95
+ // always use TIP-1009 expiring nonces. `feePayer` controls sponsorship only.
94
96
  // viem's stable transaction request type does not yet expose Tempo's
95
97
  // `calls`, `feePayer`, and `feeToken` fields together. Keep the cast at
96
98
  // this boundary so session credential builders stay typed.
97
99
  return prepareTransactionRequest(client, {
98
100
  account,
99
101
  calls,
100
- ...(feePayer ? { feePayer: true, ...(validAfter !== undefined ? { validAfter } : {}) } : {}),
102
+ nonceKey: 'expiring',
103
+ ...(feePayer ? { feePayer: true } : {}),
101
104
  feeToken,
105
+ ...(validAfter !== undefined ? { validAfter } : {}),
102
106
  } as never)
103
107
  }
104
108
 
@@ -227,6 +231,8 @@ export async function createOpenPayload(
227
231
  initialAmount: bigint
228
232
  operator?: Address | undefined
229
233
  payee: Address
234
+ /** Calls executed atomically before the channel is opened. */
235
+ prefixCalls?: readonly TempoChannelCall[] | undefined
230
236
  token: Address
231
237
  },
232
238
  ): Promise<OpenCredentialPayload> {
@@ -244,7 +250,7 @@ export async function createOpenPayload(
244
250
  })
245
251
  const prepared = await prepareTempoChannelTransaction(client, {
246
252
  account,
247
- calls: [{ to: escrow, data: openData }],
253
+ calls: [...(parameters.prefixCalls ?? []), { to: escrow, data: openData }],
248
254
  feePayer: parameters.feePayer,
249
255
  feeToken: parameters.token,
250
256
  })
@@ -304,6 +310,7 @@ export async function createTopUpPayload(
304
310
  chainId: number,
305
311
  feePayer?: boolean | undefined,
306
312
  escrow: Address = tip20ChannelEscrow,
313
+ prefixCalls: readonly TempoChannelCall[] = [],
307
314
  ): Promise<TopUpCredentialPayload> {
308
315
  const channelId = Channel.computeId({
309
316
  ...descriptor,
@@ -314,6 +321,7 @@ export async function createTopUpPayload(
314
321
  const prepared = await prepareTempoChannelTransaction(client, {
315
322
  account,
316
323
  calls: [
324
+ ...prefixCalls,
317
325
  {
318
326
  to: escrow,
319
327
  data: encodeFunctionData({
@@ -325,7 +333,7 @@ export async function createTopUpPayload(
325
333
  ],
326
334
  feePayer,
327
335
  feeToken: descriptor.token,
328
- ...(feePayer ? { validAfter: randomValidAfter() } : {}),
336
+ validAfter: randomValidAfter(),
329
337
  })
330
338
  const transaction = await signPreparedTempoTransaction(client, prepared)
331
339
 
@@ -11,6 +11,7 @@ import type * as Challenge from '../../../Challenge.js'
11
11
  import * as Constants from '../../../Constants.js'
12
12
  import * as Account from '../../../viem/Account.js'
13
13
  import * as z from '../../../zod.js'
14
+ import * as AutoSwap from '../../internal/auto-swap.js'
14
15
  import * as Chain from '../precompile/Chain.js'
15
16
  import * as Channel from '../precompile/Channel.js'
16
17
  import type { SessionCredentialPayload } from '../precompile/Protocol.js'
@@ -25,6 +26,7 @@ import {
25
26
  resolveAuthorizedSigner,
26
27
  resolveEscrow,
27
28
  type ChannelEntry,
29
+ type TempoChannelCall,
28
30
  } from './ChannelOps.js'
29
31
  import { channelKey, entryKey, type ChannelSink } from './ChannelStore.js'
30
32
  import { assertWithinMaxDeposit, resolveOpeningDeposit } from './Runtime.js'
@@ -88,6 +90,7 @@ const hashSchema = z.custom<Hex>(
88
90
  /** Runtime schema for low-level TIP-1034 session credential context. */
89
91
  export const sessionContextSchema = z.object({
90
92
  account: z.optional(z.custom<Account.getResolver.Parameters['account']>()),
93
+ autoSwap: z.optional(z.custom<AutoSwap.resolve.Value>()),
91
94
  action: z.optional(z.enum(['open', 'topUp', 'voucher', 'close'])),
92
95
  channelId: z.optional(hashSchema),
93
96
  cumulativeAmount: z.optional(z.amount()),
@@ -103,6 +106,8 @@ export const sessionContextSchema = z.object({
103
106
  export type SessionContext = {
104
107
  /** Optional account override used for this credential only. */
105
108
  account?: Account.getResolver.Parameters['account'] | undefined
109
+ /** Automatically acquire the session currency from fallback stablecoins before open/top-up. */
110
+ autoSwap?: AutoSwap.resolve.Value | undefined
106
111
  /** Manual credential action. Omit for automatic open/recover/voucher management. */
107
112
  action?: 'open' | 'topUp' | 'voucher' | 'close' | undefined
108
113
  /** Channel ID being reused or manually operated on. */
@@ -497,7 +502,7 @@ function assertReusableChannelDescriptor(parameters: {
497
502
  throw new Error('context descriptor token does not match challenge')
498
503
  if (!isSameAddress(descriptor.payer, payer))
499
504
  throw new Error('context descriptor payer does not match account')
500
- if (!isSameAddress(descriptor.authorizedSigner, authorizedSigner))
505
+ if (!isSameAddress(Channel.resolveAuthorizedSigner(descriptor), authorizedSigner))
501
506
  throw new Error('context descriptor authorizedSigner does not match account')
502
507
  }
503
508
 
@@ -558,10 +563,6 @@ export async function hydrateSessionSnapshot(
558
563
  if (snapshotSettled > snapshotDeposit)
559
564
  throw new Error('session snapshot settled amount exceeds deposit')
560
565
 
561
- const authorizedSigner =
562
- BigInt(snapshot.descriptor.authorizedSigner) === 0n
563
- ? snapshot.descriptor.payer
564
- : snapshot.descriptor.authorizedSigner
565
566
  if (
566
567
  !Voucher.verifyVoucher(
567
568
  snapshot.escrow,
@@ -571,7 +572,7 @@ export async function hydrateSessionSnapshot(
571
572
  cumulativeAmount: voucherCumulative,
572
573
  signature: signed.signature,
573
574
  },
574
- authorizedSigner,
575
+ Channel.resolveAuthorizedSigner(snapshot.descriptor),
575
576
  )
576
577
  )
577
578
  throw new Error('session snapshot highest voucher signature is invalid')
@@ -618,9 +619,10 @@ export function canSignDescriptor(
618
619
  ): boolean {
619
620
  // Only the payer can deposit into and voucher against its own channel.
620
621
  if (!isSameAddress(account.address, descriptor.payer)) return false
621
- const authority = descriptor.authorizedSigner
622
- if (BigInt(authority) === 0n || isSameAddress(authority, descriptor.payer)) return true
623
- return isSameAddress(resolveAuthorizedSigner(account), authority)
622
+ return isSameAddress(
623
+ resolveAuthorizedSigner(account),
624
+ Channel.resolveAuthorizedSigner(descriptor),
625
+ )
624
626
  }
625
627
 
626
628
  /** Chooses the next credential plan from local channel cache and optional caller context. */
@@ -661,22 +663,43 @@ export function planCredential(parameters: PlanCredentialParameters): Credential
661
663
  export async function executeCredentialPlan(
662
664
  plan: CredentialPlan,
663
665
  sink: ChannelSink,
666
+ autoSwap: AutoSwap.resolve.Resolved | false = false,
664
667
  ): Promise<SessionCredentialPayload> {
665
668
  switch (plan.type) {
666
669
  case 'open':
667
- return open(plan, sink)
670
+ return open(plan, sink, autoSwap)
668
671
  case 'recover':
669
672
  return recover(plan, sink)
670
673
  case 'voucher':
671
674
  return voucher(plan, sink)
672
675
  case 'manual':
673
- return manual(plan, sink)
676
+ return manual(plan, sink, autoSwap)
674
677
  }
675
678
  }
676
679
 
680
+ async function resolveAutoSwapCalls(
681
+ autoSwap: AutoSwap.resolve.Resolved | false,
682
+ parameters: {
683
+ account: Address
684
+ amountOut: bigint
685
+ client: Client
686
+ tokenOut: Address
687
+ },
688
+ ): Promise<readonly TempoChannelCall[] | undefined> {
689
+ if (!autoSwap) return undefined
690
+ return (await AutoSwap.findCalls(parameters.client, {
691
+ account: parameters.account,
692
+ amountOut: parameters.amountOut,
693
+ tokenOut: parameters.tokenOut,
694
+ tokenIn: autoSwap.tokenIn,
695
+ slippage: autoSwap.slippage,
696
+ })) as readonly TempoChannelCall[] | undefined
697
+ }
698
+
677
699
  async function open(
678
700
  plan: Extract<CredentialPlan, { type: 'open' }>,
679
701
  sink: ChannelSink,
702
+ autoSwap: AutoSwap.resolve.Resolved | false,
680
703
  ): Promise<SessionCredentialPayload> {
681
704
  const { account, resolved } = plan
682
705
  const deposit = resolveOpeningDeposit({
@@ -693,6 +716,12 @@ async function open(
693
716
  initialAmount: resolved.amount,
694
717
  operator: resolved.operator,
695
718
  payee: resolved.payee,
719
+ prefixCalls: await resolveAutoSwapCalls(autoSwap, {
720
+ account: account.address,
721
+ amountOut: deposit,
722
+ client: resolved.client,
723
+ tokenOut: resolved.token,
724
+ }),
696
725
  token: resolved.token,
697
726
  })
698
727
  await storeChannelEntry(sink, {
@@ -779,6 +808,7 @@ async function voucher(
779
808
  async function manual(
780
809
  plan: Extract<CredentialPlan, { type: 'manual' }>,
781
810
  sink: ChannelSink,
811
+ autoSwap: AutoSwap.resolve.Resolved | false,
782
812
  ): Promise<SessionCredentialPayload> {
783
813
  const { account, context, decimals, resolved } = plan
784
814
  const { descriptor } = context
@@ -797,26 +827,30 @@ async function manual(
797
827
  token: resolved.token,
798
828
  })
799
829
 
800
- const payload = await executeManualCredential({
801
- account,
802
- channelId,
803
- context,
804
- decimals,
805
- descriptor,
806
- resolved,
807
- })
830
+ const payload = await executeManualCredential(
831
+ {
832
+ account,
833
+ channelId,
834
+ context,
835
+ decimals,
836
+ descriptor,
837
+ resolved,
838
+ },
839
+ autoSwap,
840
+ )
808
841
  await applyCumulative(sink, resolved.key, payload)
809
842
  return payload
810
843
  }
811
844
 
812
845
  async function executeManualCredential(
813
846
  parameters: ManualCredentialParameters,
847
+ autoSwap: AutoSwap.resolve.Resolved | false,
814
848
  ): Promise<SessionCredentialPayload> {
815
849
  switch (parameters.context.action) {
816
850
  case 'open':
817
851
  return manualOpen(parameters)
818
852
  case 'topUp':
819
- return manualTopUp(parameters)
853
+ return manualTopUp(parameters, autoSwap)
820
854
  case 'voucher':
821
855
  return manualVoucher(parameters)
822
856
  case 'close':
@@ -852,6 +886,7 @@ async function manualOpen(
852
886
 
853
887
  async function manualTopUp(
854
888
  parameters: ManualCredentialParameters,
889
+ autoSwap: AutoSwap.resolve.Resolved | false,
855
890
  ): Promise<SessionCredentialPayload> {
856
891
  const { account, channelId, context, decimals, descriptor, resolved } = parameters
857
892
  const additionalDeposit = requireContextAmount(context, decimals, 'additionalDeposit', 'topUp')
@@ -873,6 +908,12 @@ async function manualTopUp(
873
908
  resolved.chainId,
874
909
  resolved.feePayer,
875
910
  resolved.escrow,
911
+ await resolveAutoSwapCalls(autoSwap, {
912
+ account: account.address,
913
+ amountOut: additionalDeposit,
914
+ client: resolved.client,
915
+ tokenOut: resolved.token,
916
+ }),
876
917
  )
877
918
  }
878
919
 
@@ -30,6 +30,7 @@ import {
30
30
  nextSpentFromReceipt,
31
31
  parseManagerAmount,
32
32
  reduce,
33
+ resolveAutomaticTopUp,
33
34
  resolveCloseTarget,
34
35
  resolveNeedVoucherTransition,
35
36
  resolveOpeningDeposit,
@@ -925,6 +926,17 @@ describe('LocalAuthorization', () => {
925
926
  )
926
927
  })
927
928
 
929
+ test.each([
930
+ [{ topUpAmount: 80n, suggestedDeposit: 100n }, 80n],
931
+ [{ suggestedDeposit: 100n }, 100n],
932
+ [{}, 50n],
933
+ [{ maxDeposit: 180n, suggestedDeposit: 100n }, 80n],
934
+ ])('resolves bounded automatic top-ups %#', (policy, expected) => {
935
+ expect(resolveAutomaticTopUp({ deposit: 100n, requiredCumulative: 150n, ...policy })).toBe(
936
+ expected,
937
+ )
938
+ })
939
+
928
940
  test('enforces optional maxDeposit through the compatibility helper', () => {
929
941
  expect(() => assertWithinMaxDeposit(101n, 100n)).toThrow(
930
942
  'requested voucher amount 101 exceeds local maxDeposit 100',
@@ -533,6 +533,28 @@ export function resolveOpeningDeposit(parameters: ResolveOpeningDepositParameter
533
533
  return proposed
534
534
  }
535
535
 
536
+ /** Resolves a bounded automatic refill, preferring explicit policy over server headroom. */
537
+ export function resolveAutomaticTopUp(parameters: {
538
+ deposit: bigint
539
+ maxDeposit?: bigint | null | undefined
540
+ requiredCumulative: bigint
541
+ suggestedDeposit?: bigint | undefined
542
+ topUpAmount?: bigint | null | undefined
543
+ }): bigint {
544
+ const { deposit, maxDeposit, requiredCumulative, suggestedDeposit, topUpAmount } = parameters
545
+ if (requiredCumulative <= deposit) return 0n
546
+ assertVoucherWithinLocalLimit({
547
+ cumulativeAmount: requiredCumulative,
548
+ maxVoucherCumulative: maxDeposit ?? null,
549
+ })
550
+ const shortfall = requiredCumulative - deposit
551
+ const preferred = topUpAmount ?? suggestedDeposit ?? shortfall
552
+ const proposed = preferred > shortfall ? preferred : shortfall
553
+ if (maxDeposit === null || maxDeposit === undefined) return proposed
554
+ const remaining = maxDeposit - deposit
555
+ return proposed < remaining ? proposed : remaining
556
+ }
557
+
536
558
  /** Enforces the optional client-side maximum cumulative voucher authorization. */
537
559
  export function assertWithinMaxDeposit(
538
560
  cumulativeAmount: bigint,
@@ -1,13 +1,15 @@
1
1
  import { type Address, createClient, custom, decodeFunctionData, encodeFunctionResult } from 'viem'
2
2
  import { privateKeyToAccount } from 'viem/accounts'
3
3
  import { Account as TempoAccount, Secp256k1, Transaction } from 'viem/tempo'
4
- import { describe, expect, test } from 'vp/test'
4
+ import { describe, expect, test, vi } from 'vp/test'
5
5
 
6
6
  import { serialize as serializeChallenge, type Challenge } from '../../../Challenge.js'
7
7
  import * as Fetch from '../../../client/internal/Fetch.js'
8
+ import * as MethodChallenge from '../../../client/internal/MethodChallenge.js'
8
9
  import * as Constants from '../../../Constants.js'
9
10
  import * as Credential from '../../../Credential.js'
10
11
  import * as z from '../../../zod.js'
12
+ import * as AutoSwap from '../../internal/auto-swap.js'
11
13
  import * as Methods from '../../Methods.js'
12
14
  import * as Channel from '../precompile/Channel.js'
13
15
  import { escrowAbi } from '../precompile/escrow.abi.js'
@@ -99,6 +101,14 @@ function openDeposit(payload: Types.SessionCredentialPayload): bigint {
99
101
  return openArgs(payload)[3]
100
102
  }
101
103
 
104
+ function transactionCalls(payload: Types.SessionCredentialPayload) {
105
+ if (payload.action !== 'open' && payload.action !== 'topUp')
106
+ throw new Error('expected transaction payload')
107
+ const transaction = Transaction.deserialize(payload.transaction)
108
+ if (!('calls' in transaction)) throw new Error('expected tempo calls')
109
+ return transaction.calls as readonly { to?: Address; data?: `0x${string}` }[]
110
+ }
111
+
102
112
  describe('precompile client session', () => {
103
113
  test('handles only TIP-1034 session challenges', () => {
104
114
  const method = session({ account, getClient: () => client })
@@ -205,7 +215,164 @@ describe('precompile client session', () => {
205
215
  expect(await response.text()).toBe(
206
216
  `${Types.formatMessageEvent('first')}${Types.formatMessageEvent('second')}`,
207
217
  )
208
- expect(actions).toEqual(['open', 'voucher', 'topUp', 'voucher'])
218
+ expect(actions).toEqual(['open', 'topUp', 'voucher', 'voucher'])
219
+ })
220
+
221
+ test('tops up before signing plain HTTP vouchers through Fetch.from', async () => {
222
+ const challenge = makeChallenge({
223
+ suggestedDeposit: '100',
224
+ methodDetails: {
225
+ chainId,
226
+ escrowContract: tip20ChannelEscrow,
227
+ sessionProtocol: Constants.SessionProtocols.v2,
228
+ },
229
+ })
230
+ const actions: Types.SessionCredentialPayload['action'][] = []
231
+ const paidRequests: {
232
+ body: BodyInit | null
233
+ header: string | null
234
+ method: string | undefined
235
+ }[] = []
236
+ let opened: Extract<Types.SessionCredentialPayload, { action: 'open' }> | undefined
237
+ const rawFetch: typeof globalThis.fetch = async (_input, init) => {
238
+ const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
239
+ if (!authorization)
240
+ return new Response(null, {
241
+ status: 402,
242
+ headers: { [Constants.Headers.wwwAuthenticate]: serializeChallenge(challenge) },
243
+ })
244
+
245
+ const payload = deserialize(authorization)
246
+ actions.push(payload.action)
247
+ if (payload.action === 'open') {
248
+ opened = payload
249
+ return new Response('opened')
250
+ }
251
+ if (payload.action === 'topUp') return new Response(null, { status: 204 })
252
+ if (payload.action !== 'voucher' || !opened) throw new Error('expected open voucher')
253
+
254
+ paidRequests.push({
255
+ body: init?.body ?? null,
256
+ header: new Headers(init?.headers).get('x-test'),
257
+ method: init?.method,
258
+ })
259
+ return new Response('paid')
260
+ }
261
+ const fetch = Fetch.from({
262
+ fetch: rawFetch,
263
+ methods: [
264
+ session({
265
+ account,
266
+ decimals: 0,
267
+ getClient: () => client,
268
+ maxDeposit: '300',
269
+ }),
270
+ ],
271
+ })
272
+
273
+ expect(await (await fetch('https://example.com/resource')).text()).toBe('opened')
274
+ const response = await fetch('https://example.com/resource', {
275
+ body: 'request-body',
276
+ headers: { 'x-test': 'preserved' },
277
+ method: 'POST',
278
+ })
279
+
280
+ expect(await response.text()).toBe('paid')
281
+ expect(actions).toEqual(['open', 'topUp', 'voucher'])
282
+ expect(paidRequests).toEqual([{ body: 'request-body', header: 'preserved', method: 'POST' }])
283
+ })
284
+
285
+ test('does not top up a stored channel owned by another account', async () => {
286
+ const channelStore = createChannelStore()
287
+ const foreign = '0x0000000000000000000000000000000000000004' as Address
288
+ await channelStore.set({
289
+ chainId,
290
+ channelId: `0x${'44'.repeat(32)}`,
291
+ cumulativeAmount: 100n,
292
+ deposit: 100n,
293
+ descriptor: { ...descriptor, authorizedSigner: foreign, payer: foreign },
294
+ escrow: tip20ChannelEscrow,
295
+ opened: true,
296
+ })
297
+ const actions: Types.SessionCredentialPayload['action'][] = []
298
+ const challenge = makeChallenge({
299
+ methodDetails: {
300
+ chainId,
301
+ escrowContract: tip20ChannelEscrow,
302
+ sessionProtocol: Constants.SessionProtocols.v2,
303
+ },
304
+ })
305
+ const fetch = Fetch.from({
306
+ fetch: async (_input, init) => {
307
+ const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
308
+ if (!authorization)
309
+ return new Response(null, {
310
+ status: 402,
311
+ headers: { [Constants.Headers.wwwAuthenticate]: serializeChallenge(challenge) },
312
+ })
313
+ actions.push(deserialize(authorization).action)
314
+ return new Response('paid')
315
+ },
316
+ methods: [session({ account, channelStore, decimals: 0, getClient: () => client })],
317
+ })
318
+
319
+ expect(await (await fetch('https://example.com/resource')).text()).toBe('paid')
320
+ expect(actions).toEqual(['open'])
321
+ })
322
+
323
+ test('uses a matching server snapshot deposit when checking headroom', async () => {
324
+ const channelStore = createChannelStore()
325
+ const channelId = Channel.computeId({ ...descriptor, chainId, escrow: tip20ChannelEscrow })
326
+ await channelStore.set({
327
+ chainId,
328
+ channelId,
329
+ cumulativeAmount: 100n,
330
+ deposit: 100n,
331
+ descriptor,
332
+ escrow: tip20ChannelEscrow,
333
+ opened: true,
334
+ })
335
+ const challenge = makeChallenge({
336
+ methodDetails: {
337
+ chainId,
338
+ escrowContract: tip20ChannelEscrow,
339
+ sessionProtocol: Constants.SessionProtocols.v2,
340
+ sessionSnapshot: {
341
+ acceptedCumulative: '100',
342
+ chainId,
343
+ channelId,
344
+ deposit: '200',
345
+ descriptor,
346
+ escrow: tip20ChannelEscrow,
347
+ requiredCumulative: '200',
348
+ settled: '0',
349
+ spent: '100',
350
+ units: 1,
351
+ },
352
+ },
353
+ })
354
+ let updatedDeposit = 0n
355
+ const method = session({
356
+ account,
357
+ channelStore,
358
+ decimals: 0,
359
+ getClient: () => client,
360
+ onChannelUpdate: (entry) => {
361
+ updatedDeposit = entry.deposit
362
+ },
363
+ })
364
+
365
+ await expect(
366
+ MethodChallenge.handle(method, {
367
+ challenge,
368
+ context: {},
369
+ fetch: async () => {
370
+ throw new Error('unexpected top-up')
371
+ },
372
+ input: 'https://example.com/resource',
373
+ }),
374
+ ).resolves.toBeUndefined()
375
+ expect(updatedDeposit).toBe(200n)
209
376
  })
210
377
 
211
378
  test('opens for the current amount without client deposit configuration', async () => {
@@ -218,6 +385,72 @@ describe('precompile client session', () => {
218
385
  expect(openDeposit(payload)).toBe(100n)
219
386
  })
220
387
 
388
+ test('atomically auto-swaps before opening a session channel', async () => {
389
+ const sourceToken = '0x0000000000000000000000000000000000000007' as Address
390
+ const swapCalls = [
391
+ { to: sourceToken, data: '0x1234' as const },
392
+ { to: '0x0000000000000000000000000000000000000008' as Address, data: '0x5678' as const },
393
+ ]
394
+ const findCalls = vi.spyOn(AutoSwap, 'findCalls').mockResolvedValue(swapCalls)
395
+ try {
396
+ const method = session({
397
+ account,
398
+ autoSwap: { slippage: 2, tokenIn: [sourceToken] },
399
+ getClient: () => client,
400
+ })
401
+ const payload = deserialize(
402
+ await method.createCredential({ challenge: makeChallenge(), context: {} }),
403
+ )
404
+
405
+ expect(findCalls).toHaveBeenCalledWith(expect.any(Object), {
406
+ account: account.address,
407
+ amountOut: 100n,
408
+ slippage: 2,
409
+ tokenIn: expect.arrayContaining([sourceToken]),
410
+ tokenOut: descriptor.token,
411
+ })
412
+ const calls = transactionCalls(payload)
413
+ expect(calls.slice(0, 2)).toEqual(swapCalls)
414
+ expect(calls[2]!.to?.toLowerCase()).toBe(tip20ChannelEscrow.toLowerCase())
415
+ expect(decodeFunctionData({ abi: escrowAbi, data: calls[2]!.data! }).functionName).toBe(
416
+ 'open',
417
+ )
418
+ } finally {
419
+ findCalls.mockRestore()
420
+ }
421
+ })
422
+
423
+ test('atomically auto-swaps before topping up a session channel', async () => {
424
+ const sourceToken = '0x0000000000000000000000000000000000000007' as Address
425
+ const swapCalls = [{ to: sourceToken, data: '0x1234' as const }]
426
+ const findCalls = vi.spyOn(AutoSwap, 'findCalls').mockResolvedValue(swapCalls)
427
+ try {
428
+ const method = session({ account, autoSwap: true, getClient: () => client })
429
+ const payload = deserialize(
430
+ await method.createCredential({
431
+ challenge: makeChallenge(),
432
+ context: { action: 'topUp', additionalDepositRaw: '250', descriptor },
433
+ }),
434
+ )
435
+
436
+ expect(findCalls).toHaveBeenCalledWith(expect.any(Object), {
437
+ account: account.address,
438
+ amountOut: 250n,
439
+ slippage: 1,
440
+ tokenIn: AutoSwap.defaultCurrencies,
441
+ tokenOut: descriptor.token,
442
+ })
443
+ const calls = transactionCalls(payload)
444
+ expect(calls[0]).toEqual(swapCalls[0])
445
+ expect(calls[1]!.to?.toLowerCase()).toBe(tip20ChannelEscrow.toLowerCase())
446
+ expect(decodeFunctionData({ abi: escrowAbi, data: calls[1]!.data! }).functionName).toBe(
447
+ 'topUp',
448
+ )
449
+ } finally {
450
+ findCalls.mockRestore()
451
+ }
452
+ })
453
+
221
454
  test('uses client chain ID when the challenge omits chain ID', async () => {
222
455
  const method = session({ account, getClient: () => client })
223
456
  const credential = deserializeCredential(