mppx 0.8.12 → 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 (266) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +16 -1
  3. package/dist/Errors.d.ts +10 -0
  4. package/dist/Errors.d.ts.map +1 -1
  5. package/dist/Errors.js +11 -2
  6. package/dist/Errors.js.map +1 -1
  7. package/dist/Method.d.ts +105 -3
  8. package/dist/Method.d.ts.map +1 -1
  9. package/dist/Method.js +102 -2
  10. package/dist/Method.js.map +1 -1
  11. package/dist/cli/account.d.ts +88 -0
  12. package/dist/cli/account.d.ts.map +1 -1
  13. package/dist/cli/account.js +39 -9
  14. package/dist/cli/account.js.map +1 -1
  15. package/dist/cli/cli.d.ts +2 -1
  16. package/dist/cli/cli.d.ts.map +1 -1
  17. package/dist/cli/cli.js +59 -2
  18. package/dist/cli/cli.js.map +1 -1
  19. package/dist/cli/sessions/Manager.d.ts +31 -0
  20. package/dist/cli/sessions/Manager.d.ts.map +1 -0
  21. package/dist/cli/sessions/Manager.js +56 -0
  22. package/dist/cli/sessions/Manager.js.map +1 -0
  23. package/dist/cli/sessions/commands.d.ts +36 -0
  24. package/dist/cli/sessions/commands.d.ts.map +1 -0
  25. package/dist/cli/sessions/commands.js +407 -0
  26. package/dist/cli/sessions/commands.js.map +1 -0
  27. package/dist/cli/sessions/request.d.ts +35 -0
  28. package/dist/cli/sessions/request.d.ts.map +1 -0
  29. package/dist/cli/sessions/request.js +271 -0
  30. package/dist/cli/sessions/request.js.map +1 -0
  31. package/dist/cli/sessions/store.d.ts +134 -0
  32. package/dist/cli/sessions/store.d.ts.map +1 -0
  33. package/dist/cli/sessions/store.js +723 -0
  34. package/dist/cli/sessions/store.js.map +1 -0
  35. package/dist/cli/utils.d.ts +2 -0
  36. package/dist/cli/utils.d.ts.map +1 -1
  37. package/dist/cli/utils.js +12 -0
  38. package/dist/cli/utils.js.map +1 -1
  39. package/dist/cli/validate/index.d.ts.map +1 -1
  40. package/dist/cli/validate/index.js +12 -6
  41. package/dist/cli/validate/index.js.map +1 -1
  42. package/dist/cli/validate/messages.d.ts +2 -0
  43. package/dist/cli/validate/messages.d.ts.map +1 -0
  44. package/dist/cli/validate/messages.js +8 -0
  45. package/dist/cli/validate/messages.js.map +1 -0
  46. package/dist/client/internal/Fetch.d.ts.map +1 -1
  47. package/dist/client/internal/Fetch.js +12 -2
  48. package/dist/client/internal/Fetch.js.map +1 -1
  49. package/dist/client/internal/MethodChallenge.d.ts +17 -0
  50. package/dist/client/internal/MethodChallenge.d.ts.map +1 -0
  51. package/dist/client/internal/MethodChallenge.js +11 -0
  52. package/dist/client/internal/MethodChallenge.js.map +1 -0
  53. package/dist/client/node.d.ts +29 -0
  54. package/dist/client/node.d.ts.map +1 -0
  55. package/dist/client/node.js +177 -0
  56. package/dist/client/node.js.map +1 -0
  57. package/dist/internal/types.d.ts +24 -0
  58. package/dist/internal/types.d.ts.map +1 -1
  59. package/dist/server/Mppx.d.ts +23 -4
  60. package/dist/server/Mppx.d.ts.map +1 -1
  61. package/dist/server/Mppx.js +91 -82
  62. package/dist/server/Mppx.js.map +1 -1
  63. package/dist/stripe/server/Charge.d.ts +8 -4
  64. package/dist/stripe/server/Charge.d.ts.map +1 -1
  65. package/dist/stripe/server/Charge.js.map +1 -1
  66. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  67. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  68. package/dist/stripe/server/internal/html.gen.js +1 -1
  69. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  70. package/dist/tempo/client/Methods.d.ts +2 -1
  71. package/dist/tempo/client/Methods.d.ts.map +1 -1
  72. package/dist/tempo/internal/fee-payer.d.ts +1 -0
  73. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  74. package/dist/tempo/internal/fee-payer.js +7 -2
  75. package/dist/tempo/internal/fee-payer.js.map +1 -1
  76. package/dist/tempo/internal/types.d.ts +10 -1
  77. package/dist/tempo/internal/types.d.ts.map +1 -1
  78. package/dist/tempo/legacy/client/ChannelOps.d.ts.map +1 -1
  79. package/dist/tempo/legacy/client/ChannelOps.js +2 -1
  80. package/dist/tempo/legacy/client/ChannelOps.js.map +1 -1
  81. package/dist/tempo/legacy/client/Session.d.ts +2 -2
  82. package/dist/tempo/legacy/server/Session.d.ts +3 -3
  83. package/dist/tempo/legacy/server/Session.d.ts.map +1 -1
  84. package/dist/tempo/legacy/server/Session.js.map +1 -1
  85. package/dist/tempo/server/Charge.d.ts +40 -78
  86. package/dist/tempo/server/Charge.d.ts.map +1 -1
  87. package/dist/tempo/server/Charge.js +289 -209
  88. package/dist/tempo/server/Charge.js.map +1 -1
  89. package/dist/tempo/server/Methods.d.ts +13 -23
  90. package/dist/tempo/server/Methods.d.ts.map +1 -1
  91. package/dist/tempo/server/Methods.js +6 -2
  92. package/dist/tempo/server/Methods.js.map +1 -1
  93. package/dist/tempo/server/Relay.d.ts +48 -0
  94. package/dist/tempo/server/Relay.d.ts.map +1 -0
  95. package/dist/tempo/server/Relay.js +177 -0
  96. package/dist/tempo/server/Relay.js.map +1 -0
  97. package/dist/tempo/server/SponsorBudget.d.ts +59 -0
  98. package/dist/tempo/server/SponsorBudget.d.ts.map +1 -0
  99. package/dist/tempo/server/SponsorBudget.js +144 -0
  100. package/dist/tempo/server/SponsorBudget.js.map +1 -0
  101. package/dist/tempo/server/Subscription.d.ts +3 -2
  102. package/dist/tempo/server/Subscription.d.ts.map +1 -1
  103. package/dist/tempo/server/Subscription.js.map +1 -1
  104. package/dist/tempo/server/index.d.ts +1 -1
  105. package/dist/tempo/server/index.d.ts.map +1 -1
  106. package/dist/tempo/server/index.js.map +1 -1
  107. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  108. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  109. package/dist/tempo/server/internal/html.gen.js +1 -1
  110. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  111. package/dist/tempo/session/Snapshot.d.ts +9 -0
  112. package/dist/tempo/session/Snapshot.d.ts.map +1 -1
  113. package/dist/tempo/session/Snapshot.js +6 -0
  114. package/dist/tempo/session/Snapshot.js.map +1 -1
  115. package/dist/tempo/session/client/ChannelOps.d.ts +7 -1
  116. package/dist/tempo/session/client/ChannelOps.d.ts.map +1 -1
  117. package/dist/tempo/session/client/ChannelOps.js +17 -3
  118. package/dist/tempo/session/client/ChannelOps.js.map +1 -1
  119. package/dist/tempo/session/client/CredentialState.d.ts +26 -2
  120. package/dist/tempo/session/client/CredentialState.d.ts.map +1 -1
  121. package/dist/tempo/session/client/CredentialState.js +99 -16
  122. package/dist/tempo/session/client/CredentialState.js.map +1 -1
  123. package/dist/tempo/session/client/Runtime.d.ts +8 -0
  124. package/dist/tempo/session/client/Runtime.d.ts.map +1 -1
  125. package/dist/tempo/session/client/Runtime.js +17 -0
  126. package/dist/tempo/session/client/Runtime.js.map +1 -1
  127. package/dist/tempo/session/client/Session.d.ts +16 -6
  128. package/dist/tempo/session/client/Session.d.ts.map +1 -1
  129. package/dist/tempo/session/client/Session.js +93 -36
  130. package/dist/tempo/session/client/Session.js.map +1 -1
  131. package/dist/tempo/session/client/SessionManager.d.ts +10 -3
  132. package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
  133. package/dist/tempo/session/client/SessionManager.js +176 -50
  134. package/dist/tempo/session/client/SessionManager.js.map +1 -1
  135. package/dist/tempo/session/client/Transports.d.ts +18 -7
  136. package/dist/tempo/session/client/Transports.d.ts.map +1 -1
  137. package/dist/tempo/session/client/Transports.js +81 -33
  138. package/dist/tempo/session/client/Transports.js.map +1 -1
  139. package/dist/tempo/session/client/internal/SessionManager.d.ts +19 -0
  140. package/dist/tempo/session/client/internal/SessionManager.d.ts.map +1 -0
  141. package/dist/tempo/session/client/internal/SessionManager.js +13 -0
  142. package/dist/tempo/session/client/internal/SessionManager.js.map +1 -0
  143. package/dist/tempo/session/precompile/Chain.d.ts.map +1 -1
  144. package/dist/tempo/session/precompile/Chain.js +109 -12
  145. package/dist/tempo/session/precompile/Chain.js.map +1 -1
  146. package/dist/tempo/session/precompile/Channel.d.ts +2 -0
  147. package/dist/tempo/session/precompile/Channel.d.ts.map +1 -1
  148. package/dist/tempo/session/precompile/Channel.js +4 -0
  149. package/dist/tempo/session/precompile/Channel.js.map +1 -1
  150. package/dist/tempo/session/server/CredentialVerification.d.ts +3 -0
  151. package/dist/tempo/session/server/CredentialVerification.d.ts.map +1 -1
  152. package/dist/tempo/session/server/CredentialVerification.js +14 -0
  153. package/dist/tempo/session/server/CredentialVerification.js.map +1 -1
  154. package/dist/tempo/session/server/MeteredStream.d.ts +7 -1
  155. package/dist/tempo/session/server/MeteredStream.d.ts.map +1 -1
  156. package/dist/tempo/session/server/MeteredStream.js +5 -4
  157. package/dist/tempo/session/server/MeteredStream.js.map +1 -1
  158. package/dist/tempo/session/server/RequestState.d.ts +5 -1
  159. package/dist/tempo/session/server/RequestState.d.ts.map +1 -1
  160. package/dist/tempo/session/server/RequestState.js +13 -5
  161. package/dist/tempo/session/server/RequestState.js.map +1 -1
  162. package/dist/tempo/session/server/Session.d.ts +21 -6
  163. package/dist/tempo/session/server/Session.d.ts.map +1 -1
  164. package/dist/tempo/session/server/Session.js +4 -1
  165. package/dist/tempo/session/server/Session.js.map +1 -1
  166. package/dist/tempo/session/server/Settlement.d.ts +20 -0
  167. package/dist/tempo/session/server/Settlement.d.ts.map +1 -1
  168. package/dist/tempo/session/server/Settlement.js +20 -0
  169. package/dist/tempo/session/server/Settlement.js.map +1 -1
  170. package/dist/tempo/session/server/Ws.d.ts +2 -0
  171. package/dist/tempo/session/server/Ws.d.ts.map +1 -1
  172. package/dist/tempo/session/server/Ws.js.map +1 -1
  173. package/dist/tempo/session/server/index.d.ts +1 -1
  174. package/dist/tempo/session/server/index.d.ts.map +1 -1
  175. package/dist/tempo/subscription/KeyAuthorization.d.ts +21 -21
  176. package/dist/validation/core.d.ts.map +1 -1
  177. package/dist/validation/core.js +6 -2
  178. package/dist/validation/core.js.map +1 -1
  179. package/dist/viem/Client.d.ts.map +1 -1
  180. package/dist/viem/Client.js +24 -17
  181. package/dist/viem/Client.js.map +1 -1
  182. package/package.json +6 -1
  183. package/src/Errors.test.ts +23 -0
  184. package/src/Errors.ts +21 -2
  185. package/src/Method.test.ts +102 -1
  186. package/src/Method.ts +241 -5
  187. package/src/cli/account.ts +45 -10
  188. package/src/cli/cli.test.ts +165 -70
  189. package/src/cli/cli.ts +67 -2
  190. package/src/cli/mcp.test.ts +11 -0
  191. package/src/cli/sessions/Manager.test.ts +249 -0
  192. package/src/cli/sessions/Manager.ts +93 -0
  193. package/src/cli/sessions/commands.ts +444 -0
  194. package/src/cli/sessions/request.test.ts +51 -0
  195. package/src/cli/sessions/request.ts +353 -0
  196. package/src/cli/sessions/store.test.ts +581 -0
  197. package/src/cli/sessions/store.ts +940 -0
  198. package/src/cli/utils.test.ts +23 -0
  199. package/src/cli/utils.ts +10 -0
  200. package/src/cli/validate/index.ts +12 -14
  201. package/src/cli/validate/messages.ts +7 -0
  202. package/src/cli/validate.test.ts +38 -5
  203. package/src/client/Mppx.test-d.ts +3 -3
  204. package/src/client/internal/Fetch.ts +16 -7
  205. package/src/client/internal/MethodChallenge.ts +30 -0
  206. package/src/client/node.test.ts +115 -0
  207. package/src/client/node.ts +247 -0
  208. package/src/internal/types.test-d.ts +21 -0
  209. package/src/internal/types.ts +33 -0
  210. package/src/server/Mppx.test-d.ts +44 -0
  211. package/src/server/Mppx.test.ts +205 -0
  212. package/src/server/Mppx.ts +167 -103
  213. package/src/stripe/Methods.test.ts +10 -0
  214. package/src/stripe/server/Charge.test-d.ts +66 -0
  215. package/src/stripe/server/Charge.ts +8 -5
  216. package/src/stripe/server/internal/html.gen.ts +1 -1
  217. package/src/tempo/PublicExports.test-d.ts +20 -0
  218. package/src/tempo/internal/fee-payer.ts +7 -3
  219. package/src/tempo/internal/types.ts +13 -2
  220. package/src/tempo/legacy/client/ChannelOps.test.ts +7 -0
  221. package/src/tempo/legacy/client/ChannelOps.ts +2 -1
  222. package/src/tempo/legacy/server/Defaults.test-d.ts +9 -0
  223. package/src/tempo/legacy/server/Session.ts +6 -5
  224. package/src/tempo/server/Charge.test.ts +337 -63
  225. package/src/tempo/server/Charge.ts +440 -270
  226. package/src/tempo/server/Methods.ts +16 -4
  227. package/src/tempo/server/Relay.test.ts +523 -0
  228. package/src/tempo/server/Relay.ts +288 -0
  229. package/src/tempo/server/SponsorBudget.test.ts +125 -0
  230. package/src/tempo/server/SponsorBudget.ts +213 -0
  231. package/src/tempo/server/Subscription.ts +6 -4
  232. package/src/tempo/server/index.ts +5 -1
  233. package/src/tempo/server/internal/html.gen.ts +1 -1
  234. package/src/tempo/session/README.md +201 -0
  235. package/src/tempo/session/Snapshot.ts +21 -0
  236. package/src/tempo/session/client/ChannelOps.test.ts +55 -0
  237. package/src/tempo/session/client/ChannelOps.ts +21 -3
  238. package/src/tempo/session/client/CredentialState.test.ts +2 -2
  239. package/src/tempo/session/client/CredentialState.ts +159 -16
  240. package/src/tempo/session/client/Runtime.test.ts +12 -0
  241. package/src/tempo/session/client/Runtime.ts +22 -0
  242. package/src/tempo/session/client/Session.test.ts +238 -5
  243. package/src/tempo/session/client/Session.ts +130 -41
  244. package/src/tempo/session/client/SessionManager.test.ts +239 -8
  245. package/src/tempo/session/client/SessionManager.ts +204 -54
  246. package/src/tempo/session/client/Transports.test.ts +181 -8
  247. package/src/tempo/session/client/Transports.ts +127 -45
  248. package/src/tempo/session/client/internal/SessionManager.ts +36 -0
  249. package/src/tempo/session/precompile/Chain.integration.test.ts +19 -1
  250. package/src/tempo/session/precompile/Chain.test.ts +223 -10
  251. package/src/tempo/session/precompile/Chain.ts +119 -12
  252. package/src/tempo/session/precompile/Channel.test.ts +9 -0
  253. package/src/tempo/session/precompile/Channel.ts +5 -0
  254. package/src/tempo/session/server/CredentialVerification.ts +18 -1
  255. package/src/tempo/session/server/MeteredStream.ts +12 -5
  256. package/src/tempo/session/server/RequestState.test.ts +47 -4
  257. package/src/tempo/session/server/RequestState.ts +15 -8
  258. package/src/tempo/session/server/Session.test.ts +461 -0
  259. package/src/tempo/session/server/Session.ts +31 -8
  260. package/src/tempo/session/server/Settlement.ts +45 -0
  261. package/src/tempo/session/server/Ws.test.ts +113 -0
  262. package/src/tempo/session/server/Ws.ts +2 -0
  263. package/src/tempo/session/server/index.ts +2 -0
  264. package/src/validation/core.ts +7 -1
  265. package/src/viem/Client.test.ts +44 -1
  266. package/src/viem/Client.ts +31 -17
@@ -11,9 +11,11 @@ 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'
18
+ import * as Voucher from '../precompile/Voucher.js'
17
19
  import type { SessionSnapshot } from '../Snapshot.js'
18
20
  import {
19
21
  createClosePayload,
@@ -24,6 +26,7 @@ import {
24
26
  resolveAuthorizedSigner,
25
27
  resolveEscrow,
26
28
  type ChannelEntry,
29
+ type TempoChannelCall,
27
30
  } from './ChannelOps.js'
28
31
  import { channelKey, entryKey, type ChannelSink } from './ChannelStore.js'
29
32
  import { assertWithinMaxDeposit, resolveOpeningDeposit } from './Runtime.js'
@@ -87,6 +90,7 @@ const hashSchema = z.custom<Hex>(
87
90
  /** Runtime schema for low-level TIP-1034 session credential context. */
88
91
  export const sessionContextSchema = z.object({
89
92
  account: z.optional(z.custom<Account.getResolver.Parameters['account']>()),
93
+ autoSwap: z.optional(z.custom<AutoSwap.resolve.Value>()),
90
94
  action: z.optional(z.enum(['open', 'topUp', 'voucher', 'close'])),
91
95
  channelId: z.optional(hashSchema),
92
96
  cumulativeAmount: z.optional(z.amount()),
@@ -102,6 +106,8 @@ export const sessionContextSchema = z.object({
102
106
  export type SessionContext = {
103
107
  /** Optional account override used for this credential only. */
104
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
105
111
  /** Manual credential action. Omit for automatic open/recover/voucher management. */
106
112
  action?: 'open' | 'topUp' | 'voucher' | 'close' | undefined
107
113
  /** Channel ID being reused or manually operated on. */
@@ -309,6 +315,26 @@ export type ResolveRecoveredCumulativeParameters = {
309
315
  settled: bigint
310
316
  }
311
317
 
318
+ /** Inputs used to validate and hydrate a server-provided session snapshot. */
319
+ export type HydrateSessionSnapshotParameters = {
320
+ /** Account expected to own the channel and its voucher authority. */
321
+ account: ViemAccount
322
+ /** Viem client used to read authoritative TIP-1034 channel state. */
323
+ client: Client
324
+ /** Snapshot returned by an MPP server bootstrap. */
325
+ snapshot: SessionSnapshot
326
+ /** Optional state reader for deterministic tests. */
327
+ readChannelState?: ReadReusableChannelState | undefined
328
+ }
329
+
330
+ /** Validated client channel state reconstructed without local persistence. */
331
+ export type HydratedSessionSnapshot = {
332
+ /** Reusable channel entry safe to cache locally. */
333
+ entry: ChannelEntry
334
+ /** Server-accounted delivered spend at bootstrap time. */
335
+ spent: bigint
336
+ }
337
+
312
338
  /** Data-first description of the next credential operation the client should execute. */
313
339
  export type CredentialPlan =
314
340
  /** No reusable channel is available, so create an open transaction and initial voucher. */
@@ -476,7 +502,7 @@ function assertReusableChannelDescriptor(parameters: {
476
502
  throw new Error('context descriptor token does not match challenge')
477
503
  if (!isSameAddress(descriptor.payer, payer))
478
504
  throw new Error('context descriptor payer does not match account')
479
- if (!isSameAddress(descriptor.authorizedSigner, authorizedSigner))
505
+ if (!isSameAddress(Channel.resolveAuthorizedSigner(descriptor), authorizedSigner))
480
506
  throw new Error('context descriptor authorizedSigner does not match account')
481
507
  }
482
508
 
@@ -501,7 +527,12 @@ export function resolveRecoveredCumulative(
501
527
  const { context, decimals, requestAmount, snapshot, settled } = parameters
502
528
 
503
529
  if (snapshot) {
504
- return BigInt(snapshot.spent) + requestAmount
530
+ return [
531
+ BigInt(snapshot.acceptedCumulative),
532
+ BigInt(snapshot.requiredCumulative),
533
+ BigInt(snapshot.spent) + requestAmount,
534
+ settled,
535
+ ].reduce((highest, value) => (value > highest ? value : highest), 0n)
505
536
  }
506
537
 
507
538
  const contextCumulative = parseOptionalContextAmount(context, decimals, 'cumulativeAmount')
@@ -509,6 +540,78 @@ export function resolveRecoveredCumulative(
509
540
  return settled + requestAmount
510
541
  }
511
542
 
543
+ /** Checks a server snapshot against its signed voucher and current TIP-1034 state. */
544
+ export async function hydrateSessionSnapshot(
545
+ parameters: HydrateSessionSnapshotParameters,
546
+ ): Promise<HydratedSessionSnapshot> {
547
+ const { account, client, readChannelState, snapshot } = parameters
548
+ const signed = snapshot.highestVoucher
549
+ if (!signed) throw new Error('session snapshot is missing its highest signed voucher')
550
+ if (signed.channelId.toLowerCase() !== snapshot.channelId.toLowerCase())
551
+ throw new Error('session snapshot voucher channelId does not match snapshot channelId')
552
+
553
+ const acceptedCumulative = BigInt(snapshot.acceptedCumulative)
554
+ const voucherCumulative = BigInt(signed.cumulativeAmount)
555
+ const requiredCumulative = BigInt(snapshot.requiredCumulative)
556
+ const snapshotDeposit = BigInt(snapshot.deposit)
557
+ const snapshotSettled = BigInt(snapshot.settled)
558
+ const spent = BigInt(snapshot.spent)
559
+ if (voucherCumulative !== acceptedCumulative)
560
+ throw new Error('session snapshot voucher amount does not match acceptedCumulative')
561
+ if (spent > acceptedCumulative)
562
+ throw new Error('session snapshot spent exceeds acceptedCumulative')
563
+ if (snapshotSettled > snapshotDeposit)
564
+ throw new Error('session snapshot settled amount exceeds deposit')
565
+
566
+ if (
567
+ !Voucher.verifyVoucher(
568
+ snapshot.escrow,
569
+ snapshot.chainId,
570
+ {
571
+ channelId: signed.channelId,
572
+ cumulativeAmount: voucherCumulative,
573
+ signature: signed.signature,
574
+ },
575
+ Channel.resolveAuthorizedSigner(snapshot.descriptor),
576
+ )
577
+ )
578
+ throw new Error('session snapshot highest voucher signature is invalid')
579
+
580
+ const reusable = await resolveReusableChannel({
581
+ channelId: snapshot.channelId,
582
+ client,
583
+ descriptor: snapshot.descriptor,
584
+ expected: {
585
+ chainId: snapshot.chainId,
586
+ escrow: snapshot.escrow,
587
+ payee: snapshot.descriptor.payee,
588
+ payer: account.address,
589
+ authorizedSigner: resolveAuthorizedSigner(account),
590
+ token: snapshot.descriptor.token,
591
+ },
592
+ readChannelState,
593
+ })
594
+ const cumulativeAmount = [acceptedCumulative, requiredCumulative, reusable.state.settled].reduce(
595
+ (highest, value) => (value > highest ? value : highest),
596
+ 0n,
597
+ )
598
+ if (cumulativeAmount > reusable.state.deposit)
599
+ throw new Error('recovered session cumulative amount exceeds on-chain channel deposit')
600
+
601
+ return {
602
+ entry: {
603
+ channelId: reusable.channelId,
604
+ cumulativeAmount,
605
+ deposit: reusable.state.deposit,
606
+ descriptor: snapshot.descriptor,
607
+ escrow: snapshot.escrow,
608
+ chainId: snapshot.chainId,
609
+ opened: true,
610
+ },
611
+ spent,
612
+ }
613
+ }
614
+
512
615
  /** Returns whether `account` can satisfy the descriptor's voucher authority. */
513
616
  export function canSignDescriptor(
514
617
  account: ViemAccount,
@@ -516,9 +619,10 @@ export function canSignDescriptor(
516
619
  ): boolean {
517
620
  // Only the payer can deposit into and voucher against its own channel.
518
621
  if (!isSameAddress(account.address, descriptor.payer)) return false
519
- const authority = descriptor.authorizedSigner
520
- if (BigInt(authority) === 0n || isSameAddress(authority, descriptor.payer)) return true
521
- return isSameAddress(resolveAuthorizedSigner(account), authority)
622
+ return isSameAddress(
623
+ resolveAuthorizedSigner(account),
624
+ Channel.resolveAuthorizedSigner(descriptor),
625
+ )
522
626
  }
523
627
 
524
628
  /** Chooses the next credential plan from local channel cache and optional caller context. */
@@ -559,22 +663,43 @@ export function planCredential(parameters: PlanCredentialParameters): Credential
559
663
  export async function executeCredentialPlan(
560
664
  plan: CredentialPlan,
561
665
  sink: ChannelSink,
666
+ autoSwap: AutoSwap.resolve.Resolved | false = false,
562
667
  ): Promise<SessionCredentialPayload> {
563
668
  switch (plan.type) {
564
669
  case 'open':
565
- return open(plan, sink)
670
+ return open(plan, sink, autoSwap)
566
671
  case 'recover':
567
672
  return recover(plan, sink)
568
673
  case 'voucher':
569
674
  return voucher(plan, sink)
570
675
  case 'manual':
571
- return manual(plan, sink)
676
+ return manual(plan, sink, autoSwap)
572
677
  }
573
678
  }
574
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
+
575
699
  async function open(
576
700
  plan: Extract<CredentialPlan, { type: 'open' }>,
577
701
  sink: ChannelSink,
702
+ autoSwap: AutoSwap.resolve.Resolved | false,
578
703
  ): Promise<SessionCredentialPayload> {
579
704
  const { account, resolved } = plan
580
705
  const deposit = resolveOpeningDeposit({
@@ -591,6 +716,12 @@ async function open(
591
716
  initialAmount: resolved.amount,
592
717
  operator: resolved.operator,
593
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
+ }),
594
725
  token: resolved.token,
595
726
  })
596
727
  await storeChannelEntry(sink, {
@@ -677,6 +808,7 @@ async function voucher(
677
808
  async function manual(
678
809
  plan: Extract<CredentialPlan, { type: 'manual' }>,
679
810
  sink: ChannelSink,
811
+ autoSwap: AutoSwap.resolve.Resolved | false,
680
812
  ): Promise<SessionCredentialPayload> {
681
813
  const { account, context, decimals, resolved } = plan
682
814
  const { descriptor } = context
@@ -695,26 +827,30 @@ async function manual(
695
827
  token: resolved.token,
696
828
  })
697
829
 
698
- const payload = await executeManualCredential({
699
- account,
700
- channelId,
701
- context,
702
- decimals,
703
- descriptor,
704
- resolved,
705
- })
830
+ const payload = await executeManualCredential(
831
+ {
832
+ account,
833
+ channelId,
834
+ context,
835
+ decimals,
836
+ descriptor,
837
+ resolved,
838
+ },
839
+ autoSwap,
840
+ )
706
841
  await applyCumulative(sink, resolved.key, payload)
707
842
  return payload
708
843
  }
709
844
 
710
845
  async function executeManualCredential(
711
846
  parameters: ManualCredentialParameters,
847
+ autoSwap: AutoSwap.resolve.Resolved | false,
712
848
  ): Promise<SessionCredentialPayload> {
713
849
  switch (parameters.context.action) {
714
850
  case 'open':
715
851
  return manualOpen(parameters)
716
852
  case 'topUp':
717
- return manualTopUp(parameters)
853
+ return manualTopUp(parameters, autoSwap)
718
854
  case 'voucher':
719
855
  return manualVoucher(parameters)
720
856
  case 'close':
@@ -750,6 +886,7 @@ async function manualOpen(
750
886
 
751
887
  async function manualTopUp(
752
888
  parameters: ManualCredentialParameters,
889
+ autoSwap: AutoSwap.resolve.Resolved | false,
753
890
  ): Promise<SessionCredentialPayload> {
754
891
  const { account, channelId, context, decimals, descriptor, resolved } = parameters
755
892
  const additionalDeposit = requireContextAmount(context, decimals, 'additionalDeposit', 'topUp')
@@ -771,6 +908,12 @@ async function manualTopUp(
771
908
  resolved.chainId,
772
909
  resolved.feePayer,
773
910
  resolved.escrow,
911
+ await resolveAutoSwapCalls(autoSwap, {
912
+ account: account.address,
913
+ amountOut: additionalDeposit,
914
+ client: resolved.client,
915
+ tokenOut: resolved.token,
916
+ }),
774
917
  )
775
918
  }
776
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(
@@ -506,7 +739,7 @@ describe('precompile client session', () => {
506
739
  expect(payload.cumulativeAmount).toBe('400')
507
740
  })
508
741
 
509
- test('ignores accepted snapshot headroom above current request spend', async () => {
742
+ test('does not decrease recovered authorization below accepted snapshot headroom', async () => {
510
743
  const method = session({ account, decimals: 0, maxDeposit: '1000', getClient: () => client })
511
744
  const channelId = Channel.computeId({ ...descriptor, chainId, escrow: tip20ChannelEscrow })
512
745
  const payload = deserialize(
@@ -535,7 +768,7 @@ describe('precompile client session', () => {
535
768
 
536
769
  expect(payload.action).toBe('voucher')
537
770
  if (payload.action !== 'voucher') throw new Error('expected voucher')
538
- expect(payload.cumulativeAmount).toBe('400')
771
+ expect(payload.cumulativeAmount).toBe('500')
539
772
  })
540
773
 
541
774
  test('caps recovered voucher authorization by maxDeposit', async () => {
@@ -593,7 +826,7 @@ describe('precompile client session', () => {
593
826
  }),
594
827
  context: {},
595
828
  }),
596
- ).rejects.toThrow('requested voucher amount 700 exceeds local maxDeposit 300')
829
+ ).rejects.toThrow('requested voucher amount 1000 exceeds local maxDeposit 300')
597
830
  })
598
831
 
599
832
  test('rejects descriptor recovery for closed or missing channels', async () => {