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
@@ -1,8 +1,8 @@
1
1
  import { type Address, parseUnits } from 'viem'
2
2
  import { tempo as tempo_chain } from 'viem/chains'
3
3
 
4
+ import * as MethodChallenge from '../../../client/internal/MethodChallenge.js'
4
5
  import * as MethodResponse from '../../../client/internal/MethodResponse.js'
5
- import * as Constants from '../../../Constants.js'
6
6
  import * as Credential from '../../../Credential.js'
7
7
  import * as Method from '../../../Method.js'
8
8
  import * as Account from '../../../viem/Account.js'
@@ -11,26 +11,36 @@ import type {
11
11
  ResolveAccount as ResolveAccount_,
12
12
  ResolveAccountInfo as ResolveAccountInfo_,
13
13
  } from '../../client/ResolveAccount.js'
14
+ import * as AutoSwap from '../../internal/auto-swap.js'
14
15
  import * as defaults from '../../internal/defaults.js'
15
16
  import * as Methods from '../../Methods.js'
16
- import { isEventStream, requireSessionCredentialContext } from '../precompile/Protocol.js'
17
+ import * as Channel from '../precompile/Channel.js'
18
+ import {
19
+ isEventStream,
20
+ readSessionChallengeAmount,
21
+ requireSessionCredentialContext,
22
+ } from '../precompile/Protocol.js'
17
23
  import { serializeCredential, type ChannelEntry } from './ChannelOps.js'
18
24
  import { createChannelStore, type ChannelStore } from './ChannelStore.js'
19
25
  import {
26
+ canSignDescriptor,
20
27
  executeCredentialPlan,
28
+ hasSessionAction,
21
29
  planCredential,
22
30
  resolveChallengeContext,
23
31
  resolveRecoverContext,
24
32
  sessionContextSchema,
33
+ type ChallengeContext,
34
+ type SessionContext as CredentialContext,
25
35
  } from './CredentialState.js'
26
- import { assertWithinMaxDeposit } from './Runtime.js'
36
+ import { assertWithinMaxDeposit, resolveAutomaticTopUp } from './Runtime.js'
27
37
  import {
28
38
  handleSseNeedVoucher,
29
- isTempoSessionChallenge,
30
- managementInput,
39
+ isTip1034SessionChallenge,
31
40
  postTopUp,
32
41
  wrapSseResponse,
33
42
  type SsePaymentDriver,
43
+ type TempoSessionChallenge,
34
44
  } from './Transports.js'
35
45
 
36
46
  export { sessionContextSchema, type SessionContext } from './CredentialState.js'
@@ -45,11 +55,13 @@ export { sessionContextSchema, type SessionContext } from './CredentialState.js'
45
55
  export function session(parameters: session.Parameters = {}) {
46
56
  const {
47
57
  account,
58
+ autoSwap: autoSwapParameter,
48
59
  channelStore,
49
60
  decimals = defaults.decimals,
50
61
  escrow: escrowOverride,
51
62
  getClient: getClientParameter,
52
63
  maxDeposit: maxDepositParameter,
64
+ topUpAmount: topUpAmountParameter,
53
65
  onChannelUpdate,
54
66
  resolveAccount,
55
67
  } = parameters
@@ -61,18 +73,35 @@ export function session(parameters: session.Parameters = {}) {
61
73
  const getAccount = Account.getResolver({ account })
62
74
  const maxDeposit =
63
75
  maxDepositParameter !== undefined ? parseUnits(maxDepositParameter, decimals) : undefined
76
+ const topUpAmount =
77
+ topUpAmountParameter !== undefined ? parseUnits(topUpAmountParameter, decimals) : undefined
64
78
  const store = channelStore ?? createChannelStore()
65
79
  const sink = { store, notifyUpdate: (entry: ChannelEntry) => onChannelUpdate?.(entry) }
66
80
 
81
+ const resolveCredentialAccount = async (
82
+ resolved: ChallengeContext,
83
+ context: CredentialContext | undefined,
84
+ entry: ChannelEntry | undefined,
85
+ ) => {
86
+ const defaultAccount = getAccount(resolved.client, context)
87
+ const descriptor = context?.action
88
+ ? context.descriptor
89
+ : (entry?.descriptor ??
90
+ resolveRecoverContext({ context, snapshot: resolved.snapshot })?.descriptor)
91
+ return (
92
+ (await resolveAccount?.({
93
+ account: defaultAccount,
94
+ chainId: resolved.chainId,
95
+ operation: {
96
+ kind: 'authorizePaymentChannel',
97
+ ...(descriptor ? { authority: Channel.resolveAuthorizedSigner(descriptor) } : {}),
98
+ },
99
+ })) ?? defaultAccount
100
+ )
101
+ }
102
+
67
103
  const method = Method.toClient(Methods.session, {
68
- canHandleChallenge({ challenge }) {
69
- return (
70
- Constants.getMethodDetail(
71
- challenge.request.methodDetails,
72
- Constants.MethodDetailKeys.sessionProtocol,
73
- ) === Constants.SessionProtocols.v2
74
- )
75
- },
104
+ canHandleChallenge: ({ challenge }) => isTip1034SessionChallenge(challenge),
76
105
  context: sessionContextSchema,
77
106
  async createCredential({ challenge, context }) {
78
107
  const resolved = await resolveChallengeContext({
@@ -80,22 +109,8 @@ export function session(parameters: session.Parameters = {}) {
80
109
  escrowOverride,
81
110
  getClient,
82
111
  })
83
- const defaultAccount = getAccount(resolved.client, context)
84
112
  const entry = await store.get(resolved.key)
85
- // Resolve recovery hints early so account selection can satisfy an existing channel authority.
86
- const recoverContext = resolveRecoverContext({ context, snapshot: resolved.snapshot })
87
- const descriptor = context?.action
88
- ? context.descriptor
89
- : (entry?.descriptor ?? recoverContext?.descriptor)
90
- const account =
91
- (await resolveAccount?.({
92
- account: defaultAccount,
93
- chainId: resolved.chainId,
94
- operation: {
95
- kind: 'authorizePaymentChannel',
96
- ...(descriptor ? { authority: descriptor.authorizedSigner } : {}),
97
- },
98
- })) ?? defaultAccount
113
+ const account = await resolveCredentialAccount(resolved, context, entry)
99
114
  const payload = await executeCredentialPlan(
100
115
  planCredential({
101
116
  account,
@@ -106,15 +121,89 @@ export function session(parameters: session.Parameters = {}) {
106
121
  resolved,
107
122
  }),
108
123
  sink,
124
+ AutoSwap.resolve(context?.autoSwap ?? autoSwapParameter, AutoSwap.defaultCurrencies),
109
125
  )
110
126
  return serializeCredential(challenge, payload, resolved.chainId, account)
111
127
  },
112
128
  })
113
129
 
130
+ const topUpChannelIfNeeded = async ({
131
+ challenge,
132
+ channel,
133
+ deposit,
134
+ fetch,
135
+ input,
136
+ requiredCumulative,
137
+ }: {
138
+ challenge: TempoSessionChallenge
139
+ channel: ChannelEntry
140
+ deposit: bigint
141
+ fetch: typeof globalThis.fetch
142
+ input: RequestInfo | URL
143
+ requiredCumulative: bigint
144
+ }) => {
145
+ const knownDeposit = channel.deposit > deposit ? channel.deposit : deposit
146
+ const additionalDeposit = resolveAutomaticTopUp({
147
+ deposit: knownDeposit,
148
+ maxDeposit,
149
+ requiredCumulative,
150
+ suggestedDeposit:
151
+ challenge.request.suggestedDeposit === undefined
152
+ ? undefined
153
+ : BigInt(challenge.request.suggestedDeposit),
154
+ topUpAmount,
155
+ })
156
+ if (additionalDeposit > 0n)
157
+ await postTopUp({
158
+ additionalDeposit,
159
+ challenge,
160
+ channel,
161
+ channelId: channel.channelId,
162
+ createSessionCredential: (challenge, context) =>
163
+ method.createCredential({ challenge, context }),
164
+ fetch,
165
+ input,
166
+ })
167
+ const nextDeposit = knownDeposit + additionalDeposit
168
+ if (nextDeposit === channel.deposit) return
169
+ const updated = { ...channel, deposit: nextDeposit }
170
+ await store.set(updated)
171
+ sink.notifyUpdate(updated)
172
+ }
173
+
174
+ MethodChallenge.register(method, async ({ challenge, context, fetch, input }) => {
175
+ if (!isTip1034SessionChallenge(challenge)) return
176
+ const sessionContext = context === undefined ? undefined : sessionContextSchema.parse(context)
177
+ if (hasSessionAction(sessionContext)) return
178
+ const resolved = await resolveChallengeContext({ challenge, escrowOverride, getClient })
179
+ const channel = await store.get(resolved.key)
180
+ if (!channel?.opened) return
181
+ const snapshot =
182
+ resolved.snapshot?.channelId.toLowerCase() === channel.channelId.toLowerCase()
183
+ ? resolved.snapshot
184
+ : undefined
185
+ const nextCumulative = channel.cumulativeAmount + readSessionChallengeAmount(challenge)
186
+ const snapshotRequired = snapshot ? BigInt(snapshot.requiredCumulative) : nextCumulative
187
+ const requiredCumulative = snapshotRequired > nextCumulative ? snapshotRequired : nextCumulative
188
+ const snapshotDeposit = snapshot ? BigInt(snapshot.deposit) : channel.deposit
189
+ const deposit = snapshotDeposit > channel.deposit ? snapshotDeposit : channel.deposit
190
+ if (requiredCumulative <= deposit && deposit === channel.deposit) return
191
+ const account = await resolveCredentialAccount(resolved, sessionContext, channel)
192
+ if (!canSignDescriptor(account, channel.descriptor)) return
193
+ await topUpChannelIfNeeded({
194
+ challenge,
195
+ channel,
196
+ deposit,
197
+ fetch,
198
+ input,
199
+ requiredCumulative,
200
+ })
201
+ })
202
+
114
203
  return MethodResponse.register(
115
204
  method,
116
205
  async ({ challenge, credential, fetch, headers, input, refetch, response, signal }) => {
117
- if (!isTempoSessionChallenge(challenge)) return response
206
+ if (!isTip1034SessionChallenge(challenge)) return response
118
207
  if (!isEventStream(response)) {
119
208
  const credentialContext = requireSessionCredentialContext(
120
209
  Credential.deserialize(credential).payload,
@@ -142,23 +231,16 @@ export function session(parameters: session.Parameters = {}) {
142
231
  method.createCredential({ challenge, context }),
143
232
  fetch,
144
233
  getChannel: () => channel ?? null,
145
- managementInput,
146
- async topUpIfNeeded({ channelId, deposit, requiredCumulative }) {
147
- if (requiredCumulative <= deposit || !channel) return
148
- const additionalDeposit = requiredCumulative - deposit
149
- await postTopUp({
150
- additionalDeposit,
234
+ async topUpIfNeeded({ deposit, requiredCumulative }) {
235
+ if (!channel) return
236
+ await topUpChannelIfNeeded({
151
237
  challenge,
152
238
  channel,
153
- channelId,
154
- createSessionCredential: (challenge, context) =>
155
- method.createCredential({ challenge, context }),
239
+ deposit: channel.deposit > deposit ? channel.deposit : deposit,
156
240
  fetch,
157
241
  input,
242
+ requiredCumulative,
158
243
  })
159
- channel.deposit += additionalDeposit
160
- await store.set(channel)
161
- sink.notifyUpdate(channel)
162
244
  },
163
245
  } satisfies SsePaymentDriver
164
246
 
@@ -179,6 +261,8 @@ export declare namespace session {
179
261
 
180
262
  type Parameters = Account.getResolver.Parameters &
181
263
  Client.getResolver.Parameters & {
264
+ /** Automatically acquire the session currency from fallback stablecoins before open/top-up. */
265
+ autoSwap?: AutoSwap.resolve.Value | undefined
182
266
  /** Pluggable persistence for reusable channels. Defaults to an in-memory store. */
183
267
  channelStore?: ChannelStore | undefined
184
268
  /** Token decimals for parsing human-readable amounts (default: 6). */
@@ -187,6 +271,11 @@ export declare namespace session {
187
271
  escrow?: Address | undefined
188
272
  /** Maximum channel deposit in human-readable units. Caps server-suggested opens and automatic top-ups. */
189
273
  maxDeposit?: string | undefined
274
+ /**
275
+ * Preferred automatic top-up size in human-readable units. When omitted,
276
+ * a bounded server `suggestedDeposit` is preferred, then the exact shortfall.
277
+ */
278
+ topUpAmount?: string | undefined
190
279
  /** Called whenever channel state changes. */
191
280
  onChannelUpdate?: ((entry: ChannelEntry) => void) | undefined
192
281
  /** Selects the account that signs this session credential after the challenge is known. */
@@ -609,6 +609,54 @@ describe('Session', () => {
609
609
  expect(s.channelId).not.toBe(storedChannelId)
610
610
  })
611
611
 
612
+ test('keeps a persisted channel after its committed top-up when the paid retry fails', async () => {
613
+ const seeded = channelEntry({ cumulativeAmount: 10_000_000n, deposit: 10_000_000n })
614
+ const { store, delete: remove, map } = makeChannelStore([seeded])
615
+ const postedPayloads: SessionCredentialPayload[] = []
616
+ let probes = 0
617
+ const mockFetch = vi.fn().mockImplementation((_input, init?: RequestInit) => {
618
+ const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
619
+ const payload = authorization
620
+ ? Credential.deserialize<SessionCredentialPayload>(authorization).payload
621
+ : undefined
622
+ if (!payload) {
623
+ probes++
624
+ if (probes > 1) throw new Error('unexpected fresh-channel retry')
625
+ return Promise.resolve(make402Response())
626
+ }
627
+
628
+ postedPayloads.push(payload)
629
+ if (payload.action === 'topUp') return Promise.resolve(new Response(null, { status: 204 }))
630
+ if (payload.action === 'voucher')
631
+ return Promise.resolve(new Response('upstream failed', { status: 500 }))
632
+ throw new Error(`unexpected ${payload.action} credential`)
633
+ })
634
+ const s = sessionManager({
635
+ account,
636
+ client,
637
+ fetch: mockFetch as typeof globalThis.fetch,
638
+ maxDeposit: '20',
639
+ channelStore: store,
640
+ })
641
+
642
+ const response = await s.fetch('https://api.example.com/data')
643
+
644
+ expect(response.status).toBe(500)
645
+ expect(postedPayloads.map((payload) => payload.action)).toEqual(['topUp', 'voucher'])
646
+ expect(remove).not.toHaveBeenCalled()
647
+ expect(map.get(entryKey(seeded))).toMatchObject({
648
+ channelId: storedChannelId,
649
+ cumulativeAmount: 10_000_000n,
650
+ deposit: 11_000_000n,
651
+ })
652
+ expect(s.channelId).toBe(storedChannelId)
653
+ expect(s.state).toMatchObject({
654
+ status: 'active',
655
+ channelId: storedChannelId,
656
+ deposit: '11000000',
657
+ })
658
+ })
659
+
612
660
  test('rolls back an optimistic open when a replacement challenge does not reference it', async () => {
613
661
  const { store, delete: remove } = makeChannelStore()
614
662
  const postedPayloads: SessionCredentialPayload[] = []
@@ -753,10 +801,11 @@ describe('Session', () => {
753
801
  await restarted.fetch('https://api.example.com/data')
754
802
 
755
803
  // The first request carries no hint header; after the 402 the restarted
756
- // manager resumes the persisted channel from the entry index with a
757
- // voucher rather than opening a new one.
804
+ // manager tops up and resumes the full persisted channel rather than
805
+ // opening a new one.
758
806
  expect(new Headers(resumeFetch.mock.calls[0]?.[1]?.headers).get('Payment-Session')).toBeNull()
759
- expect(posted[0]).toMatchObject({ action: 'voucher', channelId })
807
+ expect(posted.map((payload) => payload.action)).toEqual(['topUp', 'voucher'])
808
+ expect(posted[1]).toMatchObject({ channelId })
760
809
  })
761
810
 
762
811
  test('persists opened channels and deletes closed channels when supported', async () => {
@@ -987,6 +1036,7 @@ describe('Session', () => {
987
1036
  client,
988
1037
  fetch: mockFetch as typeof globalThis.fetch,
989
1038
  maxDeposit: '10',
1039
+ topUpAmount: '5',
990
1040
  })
991
1041
 
992
1042
  await s.fetch('https://api.example.com/data')
@@ -994,10 +1044,13 @@ describe('Session', () => {
994
1044
 
995
1045
  expect(response.status).toBe(200)
996
1046
  expect(postedPayloads.map((payload) => payload.action)).toEqual(['open', 'topUp', 'voucher'])
1047
+ const topUp = postedPayloads[1]
1048
+ if (topUp?.action !== 'topUp') throw new Error('expected top-up payload')
1049
+ expect(topUp.additionalDeposit).toBe('5000000')
997
1050
  expect(s.state).toMatchObject({
998
1051
  status: 'active',
999
1052
  acceptedCumulative: '2000000',
1000
- deposit: '2000000',
1053
+ deposit: '6000000',
1001
1054
  spent: '2000000',
1002
1055
  units: 2,
1003
1056
  })
@@ -1398,6 +1451,51 @@ describe('Session', () => {
1398
1451
  })
1399
1452
  })
1400
1453
 
1454
+ describe('.ws()', () => {
1455
+ test('tops up a full persisted channel before constructing the socket', async () => {
1456
+ const events: string[] = []
1457
+ const { map, set, store } = makeChannelStore([
1458
+ channelEntry({ cumulativeAmount: 1_000_000n, deposit: 1_000_000n }),
1459
+ ])
1460
+ set.mockImplementation((entry) => {
1461
+ map.set(entryKey(entry), entry)
1462
+ events.push(`set:${entry.deposit}:${entry.cumulativeAmount}`)
1463
+ })
1464
+ const fetch = vi.fn(async (_input, init?: RequestInit) => {
1465
+ const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
1466
+ if (!authorization) {
1467
+ events.push('probe')
1468
+ return make402Response(makeChallenge({ suggestedDeposit: '1000000' }))
1469
+ }
1470
+ const payload = Credential.deserialize<SessionCredentialPayload>(authorization).payload
1471
+ if (payload.action !== 'topUp') throw new Error(`unexpected ${payload.action} fetch`)
1472
+ events.push('topUp')
1473
+ return new Response(null, { status: 204 })
1474
+ })
1475
+ function WebSocket() {
1476
+ events.push('socket')
1477
+ throw new Error('stop after preparation')
1478
+ }
1479
+ const s = sessionManager({
1480
+ account,
1481
+ client,
1482
+ fetch: fetch as typeof globalThis.fetch,
1483
+ maxDeposit: '3',
1484
+ channelStore: store,
1485
+ webSocket: WebSocket as never,
1486
+ })
1487
+
1488
+ await expect(s.ws('wss://api.example.com/socket')).rejects.toThrow('stop after preparation')
1489
+ expect(events).toEqual([
1490
+ 'probe',
1491
+ 'topUp',
1492
+ 'set:2000000:1000000',
1493
+ 'set:2000000:2000000',
1494
+ 'socket',
1495
+ ])
1496
+ })
1497
+ })
1498
+
1401
1499
  describe('.close()', () => {
1402
1500
  test('is no-op when not opened', async () => {
1403
1501
  const mockFetch = vi.fn()
@@ -4,6 +4,7 @@ import { tempo as tempo_chain } from 'viem/chains'
4
4
 
5
5
  import * as Challenge from '../../../Challenge.js'
6
6
  import * as Fetch from '../../../client/internal/Fetch.js'
7
+ import * as MethodChallenge from '../../../client/internal/MethodChallenge.js'
7
8
  import * as MethodResponse from '../../../client/internal/MethodResponse.js'
8
9
  import * as Constants from '../../../Constants.js'
9
10
  import * as Account from '../../../viem/Account.js'
@@ -16,7 +17,7 @@ import { hydrateSessionSnapshot, type SessionContext } from '../client/Credentia
16
17
  import { session as sessionPlugin } from '../client/Session.js'
17
18
  import * as Channel from '../precompile/Channel.js'
18
19
  import { deserializeSessionReceipt } from '../precompile/Protocol.js'
19
- import { readSessionChallengeAmount, type SessionReceipt } from '../precompile/Protocol.js'
20
+ import type { SessionReceipt } from '../precompile/Protocol.js'
20
21
  import {
21
22
  deserializeSnapshot as deserializeSessionSnapshot,
22
23
  serializeSnapshot as serializeSessionSnapshot,
@@ -34,6 +35,7 @@ import {
34
35
  dispatchSessionEvent,
35
36
  restoreCumulativeAuthorization,
36
37
  restoreRuntimeSnapshot as restoreRuntimeStateSnapshot,
38
+ resolveAutomaticTopUp,
37
39
  type RuntimeSnapshot,
38
40
  } from './Runtime.js'
39
41
  import { closeSocketSession } from './Runtime.js'
@@ -41,7 +43,6 @@ import {
41
43
  closeHttpSession,
42
44
  getSessionSnapshot,
43
45
  isTempoSessionChallenge,
44
- managementInput,
45
46
  postTopUp,
46
47
  retryHttpPaymentRequired,
47
48
  type TempoSessionChallenge,
@@ -111,6 +112,8 @@ type SessionManagerConfig = {
111
112
  fetch: typeof globalThis.fetch
112
113
  /** Local maximum cumulative voucher authorization, or null when uncapped. */
113
114
  maxVoucherCumulative: bigint | null
115
+ /** Preferred top-up, or null to use a bounded server suggestion and then the exact shortfall. */
116
+ topUpAmount: bigint | null
114
117
  /** WebSocket constructor available in the current runtime, when configured. */
115
118
  WebSocket: WebSocketConstructor | undefined
116
119
  }
@@ -168,6 +171,8 @@ function resolveSessionManagerConfig(parameters: sessionManager.Parameters): Ses
168
171
  fetch: parameters.fetch ?? globalThis.fetch.bind(globalThis),
169
172
  maxVoucherCumulative:
170
173
  parameters.maxDeposit !== undefined ? parseUnits(parameters.maxDeposit, decimals) : null,
174
+ topUpAmount:
175
+ parameters.topUpAmount !== undefined ? parseUnits(parameters.topUpAmount, decimals) : null,
171
176
  WebSocket,
172
177
  }
173
178
  }
@@ -201,6 +206,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
201
206
  // Tracks one fetch's channel reuse so stale stored entries can be evicted once.
202
207
  type ChannelUse = {
203
208
  challengesReceived: number
209
+ committed: RuntimeSnapshot | undefined
204
210
  created: Map<string, ChannelEntry>
205
211
  seenExisting: Set<string>
206
212
  previous: RuntimeSnapshot
@@ -221,7 +227,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
221
227
  const entry = await getReusable(key)
222
228
  if (entry && channelUse) {
223
229
  channelUse.seenExisting.add(key)
224
- if (!channelUse.created.has(key)) channelUse.resumed ??= entry
230
+ if (!channelUse.committed && !channelUse.created.has(key)) channelUse.resumed ??= entry
225
231
  }
226
232
  return entry
227
233
  },
@@ -270,13 +276,32 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
270
276
  return dispatchSessionEvent(runtime, event)
271
277
  }
272
278
 
279
+ function commitDurableTopUp(entry: ChannelEntry) {
280
+ const use = channelUse
281
+ const baseline = use?.committed?.channel?.entry ?? use?.resumed
282
+ if (
283
+ !use ||
284
+ baseline?.channelId.toLowerCase() !== entry.channelId.toLowerCase() ||
285
+ entry.deposit <= baseline.deposit
286
+ )
287
+ return
288
+ use.resumed = undefined
289
+ use.committed = captureRuntimeStateSnapshot({
290
+ channel: runtime.channel,
291
+ spent: runtime.spent,
292
+ state: runtime.state,
293
+ })
294
+ }
295
+
273
296
  const method = sessionPlugin({
274
297
  account: parameters.account,
298
+ autoSwap: parameters.autoSwap,
275
299
  getClient: parameters.client ? () => parameters.client! : parameters.getClient,
276
300
  resolveAccount: parameters.resolveAccount,
277
301
  escrow: parameters.escrow,
278
302
  decimals: config.decimals,
279
303
  maxDeposit: parameters.maxDeposit,
304
+ topUpAmount: parameters.topUpAmount,
280
305
  channelStore: store,
281
306
  onChannelUpdate(entry) {
282
307
  if (entry.channelId !== runtime.channel?.channelId) runtime.spent = 0n
@@ -290,6 +315,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
290
315
  units: 0,
291
316
  })
292
317
  }
318
+ commitDurableTopUp(entry)
293
319
  },
294
320
  })
295
321
  MethodResponse.unregister(method)
@@ -313,17 +339,6 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
313
339
  if (use) use.challengesReceived++
314
340
  runtime.lastChallenge = challenge
315
341
  dispatch({ type: 'challengeReceived', challengeId: challenge.id })
316
- if (runtime.channel?.opened && runtime.lastUrl) {
317
- const requiredCumulative =
318
- runtime.channel.cumulativeAmount + readSessionChallengeAmount(challenge)
319
- await topUpIfNeeded({
320
- challenge,
321
- input: runtime.lastUrl,
322
- channelId: runtime.channel.channelId,
323
- deposit: runtime.channel.deposit,
324
- requiredCumulative,
325
- })
326
- }
327
342
  return undefined
328
343
  },
329
344
  })
@@ -366,16 +381,12 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
366
381
  const snapshot = deserializeSessionSnapshot(header)
367
382
  const client = await getClient({ chainId: snapshot.chainId })
368
383
  const defaultAccount = getAccount(client)
369
- const authority =
370
- BigInt(snapshot.descriptor.authorizedSigner) === 0n
371
- ? snapshot.descriptor.payer
372
- : snapshot.descriptor.authorizedSigner
373
384
  const account =
374
385
  (await parameters.resolveAccount?.({
375
386
  account: defaultAccount,
376
387
  chainId: snapshot.chainId,
377
388
  operation: {
378
- authority,
389
+ authority: Channel.resolveAuthorizedSigner(snapshot.descriptor),
379
390
  kind: 'authorizePaymentChannel',
380
391
  },
381
392
  })) ?? defaultAccount
@@ -520,9 +531,11 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
520
531
  challenge: TempoSessionChallenge
521
532
  channelId: Hex.Hex
522
533
  input: RequestInfo | URL
534
+ knownDeposit?: bigint | undefined
523
535
  }) {
536
+ const { knownDeposit, ...topUp } = parameters
524
537
  const receipt = await postTopUp({
525
- ...parameters,
538
+ ...topUp,
526
539
  channel: runtime.channel,
527
540
  createSessionCredential,
528
541
  fetch: config.fetch,
@@ -534,6 +547,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
534
547
  channelId: parameters.channelId,
535
548
  challengeId: runtime.lastChallenge?.id,
536
549
  currentState: runtime.state,
550
+ knownDeposit,
537
551
  receipt,
538
552
  spent: runtime.spent,
539
553
  })
@@ -546,18 +560,32 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
546
560
  spent: runtime.spent.toString(),
547
561
  units: runtime.state.status === 'active' ? runtime.state.units : 0,
548
562
  })
563
+ commitDurableTopUp(applied.channel)
549
564
  }
550
565
  return receipt
551
566
  }
552
567
 
553
568
  async function topUpIfNeeded(parameters: TopUpRequirement) {
554
- if (parameters.requiredCumulative <= parameters.deposit) return
555
- assertVoucherWithinLocalLimit(parameters.requiredCumulative)
569
+ const channelDeposit =
570
+ runtime.channel?.channelId === parameters.channelId ? runtime.channel.deposit : 0n
571
+ const deposit = channelDeposit > parameters.deposit ? channelDeposit : parameters.deposit
572
+ const additionalDeposit = resolveAutomaticTopUp({
573
+ deposit,
574
+ maxDeposit: config.maxVoucherCumulative,
575
+ requiredCumulative: parameters.requiredCumulative,
576
+ suggestedDeposit:
577
+ parameters.challenge.request.suggestedDeposit === undefined
578
+ ? undefined
579
+ : BigInt(parameters.challenge.request.suggestedDeposit),
580
+ topUpAmount: config.topUpAmount,
581
+ })
582
+ if (additionalDeposit === 0n) return
556
583
  await postTopUpAndApply({
557
584
  challenge: parameters.challenge,
558
585
  input: parameters.input,
559
586
  channelId: parameters.channelId,
560
- additionalDeposit: parameters.requiredCumulative - parameters.deposit,
587
+ additionalDeposit,
588
+ knownDeposit: deposit,
561
589
  })
562
590
  }
563
591
 
@@ -617,6 +645,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
617
645
  })
618
646
  const use: ChannelUse = {
619
647
  challengesReceived: 0,
648
+ committed: undefined,
620
649
  created: new Map(),
621
650
  previous,
622
651
  seenExisting: new Set(),
@@ -650,7 +679,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
650
679
  try {
651
680
  response = await wrappedFetch(input, effectiveInit)
652
681
  } catch (error) {
653
- restoreRuntime(previous)
682
+ restoreRuntime(use.committed ?? previous)
654
683
  if (await retryWithoutResumed()) continue
655
684
  throw error
656
685
  }
@@ -677,7 +706,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
677
706
  }
678
707
  }
679
708
  if (!attemptedHttpManagement && !paymentResponse.ok && !paymentResponse.receipt) {
680
- restoreRuntime(previous)
709
+ restoreRuntime(use.committed ?? previous)
681
710
  if (await retryWithoutResumed()) continue
682
711
  return paymentResponse
683
712
  }
@@ -719,7 +748,6 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
719
748
  getChannel: () => runtime.channel,
720
749
  getChallenge: () => runtime.lastChallenge,
721
750
  assertVoucherWithinLocalLimit,
722
- managementInput,
723
751
  acceptReceipt(receipt: SessionReceipt) {
724
752
  updateSpentFromReceipt(receipt)
725
753
  },
@@ -777,7 +805,16 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
777
805
  const liveHint = runtime.channel?.opened ? runtime.channel.channelId : undefined
778
806
 
779
807
  const prepared = await prepareWebSocketSession({
780
- createSessionCredential,
808
+ async createSessionCredential(challenge, context) {
809
+ runtime.lastChallenge = challenge
810
+ await MethodChallenge.handle(method, {
811
+ challenge,
812
+ context,
813
+ fetch: config.fetch,
814
+ input: probeUrl,
815
+ })
816
+ return createSessionCredential(challenge, context)
817
+ },
781
818
  fetch: config.fetch,
782
819
  input,
783
820
  onProbeUrl(httpUrl) {
@@ -884,6 +921,8 @@ export namespace sessionManager {
884
921
 
885
922
  export type Parameters = Account.getResolver.Parameters &
886
923
  Client.getResolver.Parameters & {
924
+ /** Automatically acquire the session currency from fallback stablecoins before open/top-up. */
925
+ autoSwap?: sessionPlugin.Parameters['autoSwap']
887
926
  /** Enables same-route HEAD bootstrap from a server session snapshot before opening a new channel. */
888
927
  bootstrap?: boolean | undefined
889
928
  /** Viem client instance. Shorthand for `getClient: () => client`. */
@@ -896,6 +935,11 @@ export namespace sessionManager {
896
935
  fetch?: typeof globalThis.fetch | undefined
897
936
  /** Maximum deposit in human-readable units (e.g. `'10'` for 10 tokens). Converted to raw units via `decimals`. */
898
937
  maxDeposit?: string | undefined
938
+ /**
939
+ * Preferred automatic top-up size in human-readable units. When omitted,
940
+ * a bounded server `suggestedDeposit` is preferred, then the exact shortfall.
941
+ */
942
+ topUpAmount?: string | undefined
899
943
  /** Selects the account that signs session credentials. */
900
944
  resolveAccount?: sessionPlugin.ResolveAccount | undefined
901
945
  /** Store for reusable session channels. Defaults to in-memory. */