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
@@ -85,6 +85,7 @@ describe('HttpManagement', () => {
85
85
  methodDetails: {
86
86
  chainId: 4217,
87
87
  escrowContract: undefined,
88
+ sessionProtocol: Constants.SessionProtocols.v2,
88
89
  ...(snapshot && { [Constants.MethodDetailKeys.sessionSnapshot]: snapshot }),
89
90
  },
90
91
  },
@@ -220,6 +221,36 @@ describe('HttpManagement', () => {
220
221
  expect(fetch).toHaveBeenCalledOnce()
221
222
  })
222
223
 
224
+ test('postTopUp retries once with the management route challenge', async () => {
225
+ const routeChallenge = { ...challenge(), id: 'management-challenge' } as TempoSessionChallenge
226
+ const createSessionCredential = vi.fn(async (challenge_) => `top-up-${challenge_.id}`)
227
+ const fetch = vi
228
+ .fn()
229
+ .mockResolvedValueOnce(response402(routeChallenge))
230
+ .mockResolvedValueOnce(
231
+ new Response(null, {
232
+ status: 204,
233
+ headers: {
234
+ [Constants.Headers.paymentReceipt]: receiptHeader(8n, 5n, routeChallenge.id),
235
+ },
236
+ }),
237
+ )
238
+
239
+ const receipt = await postTopUp({
240
+ additionalDeposit: 3n,
241
+ challenge: challenge(),
242
+ channel: channel(),
243
+ channelId,
244
+ createSessionCredential,
245
+ fetch,
246
+ input: 'https://example.test/resource',
247
+ })
248
+
249
+ expect(receipt?.challengeId).toBe(routeChallenge.id)
250
+ expect(createSessionCredential).toHaveBeenCalledTimes(2)
251
+ expect(authorizationHeader(fetch.mock.calls[1]?.[1])).toBe(`top-up-${routeChallenge.id}`)
252
+ })
253
+
223
254
  test('retryHttpPaymentRequired signs the server-required cumulative voucher', async () => {
224
255
  let entry = channel({ cumulativeAmount: 5n, deposit: 6n })
225
256
  const topUpIfNeeded = vi.fn(async () => {
@@ -874,7 +905,6 @@ describe('SseDriver', () => {
874
905
  fetch: vi.fn(async () => new Response(null, { status: 204 })),
875
906
  getChallenge: () => currentChallenge,
876
907
  getChannel: () => channel,
877
- managementInput: (input) => input,
878
908
  async topUpIfNeeded() {},
879
909
  })
880
910
 
@@ -1011,6 +1041,7 @@ describe('WsDriver', () => {
1011
1041
  methodDetails: {
1012
1042
  chainId: 4217,
1013
1043
  escrowContract: tip20ChannelEscrow,
1044
+ sessionProtocol: Constants.SessionProtocols.v2,
1014
1045
  },
1015
1046
  recipient: '0x742d35cc6634c0532925a3b844bc9e7595f8fe00',
1016
1047
  },
@@ -268,6 +268,8 @@ export type ApplyTopUpResultParameters = {
268
268
  challengeId?: string | undefined
269
269
  /** Current active machine state, used to preserve paid unit count when possible. */
270
270
  currentState: SessionState
271
+ /** Highest deposit known before the top-up. */
272
+ knownDeposit?: bigint | undefined
271
273
  /** Receipt returned by the top-up POST, when present. */
272
274
  receipt?: SessionReceipt | undefined
273
275
  /** Latest locally observed spend in raw units. */
@@ -362,26 +364,36 @@ export function resolveManualTopUp(parameters: ResolveManualTopUpParameters): Ma
362
364
  /**
363
365
  * Applies local deposit and state bookkeeping for a top-up response.
364
366
  *
365
- * `deposit` is updated from the top-up amount. Receipt cumulative values only
366
- * update accepted spend authorization; they do not replace deposit.
367
+ * Deposit advances from the highest known baseline. Receipt cumulative values
368
+ * only update accepted spend authorization; they do not replace deposit.
367
369
  */
368
370
  export function applyTopUpResult(
369
371
  parameters: ApplyTopUpResultParameters,
370
372
  ): AppliedTopUpResult | undefined {
371
- const { additionalDeposit, channel, channelId, challengeId, currentState, receipt, spent } =
372
- parameters
373
+ const {
374
+ additionalDeposit,
375
+ channel,
376
+ channelId,
377
+ challengeId,
378
+ currentState,
379
+ knownDeposit,
380
+ receipt,
381
+ spent,
382
+ } = parameters
373
383
  if (channel?.channelId !== channelId) return undefined
374
384
 
375
- channel.deposit += additionalDeposit
385
+ const baseline =
386
+ knownDeposit !== undefined && knownDeposit > channel.deposit ? knownDeposit : channel.deposit
387
+ const updated = { ...channel, deposit: baseline + additionalDeposit }
376
388
 
377
- if (receipt) return { channel, state: activeStateFromReceipt(receipt, channel) }
378
- if (!challengeId) return { channel }
389
+ if (receipt) return { channel: updated, state: activeStateFromReceipt(receipt, updated) }
390
+ if (!challengeId) return { channel: updated }
379
391
 
380
392
  return {
381
- channel,
393
+ channel: updated,
382
394
  state: activeStateFromChannel({
383
395
  challengeId,
384
- entry: channel,
396
+ entry: updated,
385
397
  spent: spent.toString(),
386
398
  units: currentState.status === 'active' ? currentState.units : 0,
387
399
  }),
@@ -521,6 +533,19 @@ export function isTempoSessionChallenge(
521
533
  )
522
534
  }
523
535
 
536
+ /** Returns true when a challenge selects the TIP-1034 session protocol. */
537
+ export function isTip1034SessionChallenge(
538
+ challenge: Challenge.Challenge,
539
+ ): challenge is TempoSessionChallenge {
540
+ return (
541
+ isTempoSessionChallenge(challenge) &&
542
+ Constants.getMethodDetail(
543
+ challenge.request.methodDetails,
544
+ Constants.MethodDetailKeys.sessionProtocol,
545
+ ) === Constants.SessionProtocols.v2
546
+ )
547
+ }
548
+
524
549
  /** Reads a server-provided session snapshot from challenge method details. */
525
550
  export function getSessionSnapshot(challenge: TempoSessionChallenge): SessionSnapshot | undefined {
526
551
  return Constants.getMethodDetail<SessionSnapshot>(
@@ -594,15 +619,17 @@ export async function postTopUp(
594
619
  throw new Error('Cannot top up session: no local channel descriptor available.')
595
620
  }
596
621
 
597
- const credential = await parameters.createSessionCredential(parameters.challenge, {
598
- action: 'topUp',
599
- channelId,
600
- descriptor: channel.descriptor,
601
- additionalDepositRaw: parameters.additionalDeposit.toString(),
602
- })
603
- const response = await parameters.fetch(managementInput(parameters.input), {
604
- method: 'POST',
605
- headers: { [Constants.Headers.authorization]: credential },
622
+ const response = await postManagementCredential({
623
+ challenge: parameters.challenge,
624
+ context: {
625
+ action: 'topUp',
626
+ channelId,
627
+ descriptor: channel.descriptor,
628
+ additionalDepositRaw: parameters.additionalDeposit.toString(),
629
+ },
630
+ createSessionCredential: parameters.createSessionCredential,
631
+ fetch: parameters.fetch,
632
+ input: parameters.input,
606
633
  })
607
634
  if (!response.ok) throw new Error(`Top-up POST failed with status ${response.status}`)
608
635
 
@@ -610,6 +637,30 @@ export async function postTopUp(
610
637
  return receiptHeader ? deserializeSessionReceipt(receiptHeader) : undefined
611
638
  }
612
639
 
640
+ /** Posts a management credential and retries once with a refreshed TIP-1034 challenge. */
641
+ async function postManagementCredential(parameters: {
642
+ challenge: TempoSessionChallenge
643
+ context: SessionContext
644
+ createSessionCredential: CreateSessionCredential
645
+ fetch: typeof globalThis.fetch
646
+ input: RequestInfo | URL
647
+ }): Promise<Response> {
648
+ const post = async (challenge: TempoSessionChallenge) =>
649
+ parameters.fetch(managementInput(parameters.input), {
650
+ method: 'POST',
651
+ headers: {
652
+ [Constants.Headers.authorization]: await parameters.createSessionCredential(
653
+ challenge,
654
+ parameters.context,
655
+ ),
656
+ },
657
+ })
658
+ const response = await post(parameters.challenge)
659
+ if (response.status !== 402) return response
660
+ const challenge = Challenge.fromResponseList(response).find(isTip1034SessionChallenge)
661
+ return challenge ? post(challenge) : response
662
+ }
663
+
613
664
  /** Retries an HTTP 402 when the server snapshot asks for more voucher headroom. */
614
665
  export async function retryHttpPaymentRequired(
615
666
  parameters: RetryHttpPaymentRequiredParameters,
@@ -662,7 +713,7 @@ export function resolveRetryHttpPaymentContext(parameters: {
662
713
  channel: ChannelEntry | null
663
714
  response: Response
664
715
  }): RetryHttpPaymentContext | undefined {
665
- const challenge = Challenge.fromResponseList(parameters.response).find(isTempoSessionChallenge)
716
+ const challenge = Challenge.fromResponseList(parameters.response).find(isTip1034SessionChallenge)
666
717
  if (!challenge) return undefined
667
718
 
668
719
  const snapshot = getSessionSnapshot(challenge)
@@ -705,7 +756,7 @@ export async function closeHttpSession(
705
756
 
706
757
  let response = await postClose(parameters.target.challenge)
707
758
  if (response.status === 402) {
708
- const challenge = Challenge.fromResponseList(response).find(isTempoSessionChallenge)
759
+ const challenge = Challenge.fromResponseList(response).find(isTip1034SessionChallenge)
709
760
  if (challenge) {
710
761
  parameters.setChallenge?.(challenge)
711
762
  response = await postClose(challenge, true)
@@ -768,8 +819,6 @@ export type OpenSseSessionParameters = {
768
819
  getChallenge(): TempoSessionChallenge | null
769
820
  /** Validates a cumulative amount against local client policy. */
770
821
  assertVoucherWithinLocalLimit(cumulativeAmount: bigint): void
771
- /** Converts a resource URL to the server's session management URL. */
772
- managementInput(input: RequestInfo | URL): RequestInfo | URL
773
822
  /** Applies an incoming receipt to manager state. */
774
823
  acceptReceipt(receipt: SessionReceipt): void
775
824
  /** Performs an automatic channel top-up when deposit is insufficient. */
@@ -783,7 +832,6 @@ export type SsePaymentDriver = Pick<
783
832
  | 'createSessionCredential'
784
833
  | 'fetch'
785
834
  | 'getChannel'
786
- | 'managementInput'
787
835
  | 'topUpIfNeeded'
788
836
  >
789
837
 
@@ -1018,17 +1066,13 @@ export async function handleSseNeedVoucher(
1018
1066
  })
1019
1067
  if (resolution.status !== 'ready') return
1020
1068
 
1021
- const credential = await parameters.driver.createSessionCredential(
1022
- parameters.challenge,
1023
- resolution.context,
1024
- )
1025
- const voucherResponse = await parameters.driver.fetch(
1026
- parameters.driver.managementInput(parameters.input),
1027
- {
1028
- method: 'POST',
1029
- headers: { [Constants.Headers.authorization]: credential },
1030
- },
1031
- )
1069
+ const voucherResponse = await postManagementCredential({
1070
+ challenge: parameters.challenge,
1071
+ context: resolution.context,
1072
+ createSessionCredential: parameters.driver.createSessionCredential,
1073
+ fetch: parameters.driver.fetch,
1074
+ input: parameters.input,
1075
+ })
1032
1076
  if (!voucherResponse.ok) {
1033
1077
  throw new Error(`Voucher POST failed with status ${voucherResponse.status}`)
1034
1078
  }
@@ -1186,7 +1230,7 @@ export async function prepareWebSocketSession(
1186
1230
  )
1187
1231
  }
1188
1232
 
1189
- const challenge = Challenge.fromResponseList(probe).find(isTempoSessionChallenge)
1233
+ const challenge = Challenge.fromResponseList(probe).find(isTip1034SessionChallenge)
1190
1234
  if (!challenge) {
1191
1235
  throw new Error(
1192
1236
  'No payment challenge received from HTTP endpoint for this WebSocket URL. The server may not require payment or did not advertise a challenge.',
@@ -1,5 +1,12 @@
1
1
  import { Hex } from 'ox'
2
- import { type Address, encodeFunctionData, isAddressEqual, parseEventLogs, zeroAddress } from 'viem'
2
+ import {
3
+ type Address,
4
+ encodeFunctionData,
5
+ isAddressEqual,
6
+ maxUint256,
7
+ parseEventLogs,
8
+ zeroAddress,
9
+ } from 'viem'
3
10
  import { sendTransaction, waitForTransactionReceipt } from 'viem/actions'
4
11
  import { Transaction } from 'viem/tempo'
5
12
  import { describe, expect, test } from 'vp/test'
@@ -42,6 +49,15 @@ function getSingleEvent(receipt: { logs: readonly unknown[] }, name: string) {
42
49
  return logs[0] as unknown as { args: Record<string, unknown> }
43
50
  }
44
51
 
52
+ function expectExpiringNonce(serializedTransaction: Hex.Hex) {
53
+ const transaction = Transaction.deserialize(
54
+ serializedTransaction as Transaction.TransactionSerializedTempo,
55
+ ) as unknown as { nonce: number; nonceKey: bigint; validBefore?: bigint }
56
+ expect(transaction.nonce).toBe(0)
57
+ expect(transaction.nonceKey).toBe(maxUint256)
58
+ expect(transaction.validBefore).toBeDefined()
59
+ }
60
+
45
61
  async function openChannel(parameters: { deposit?: bigint | undefined } = {}) {
46
62
  const deposit = uint96(parameters.deposit ?? 1_000n)
47
63
  const salt = Hex.random(32)
@@ -120,6 +136,7 @@ describe.runIf(isPrecompileTestnet)('TIP20EscrowChannel precompile chain operati
120
136
  token: asset,
121
137
  })
122
138
  if (payload.action !== 'open') throw new Error('expected open payload')
139
+ expectExpiringNonce(payload.transaction)
123
140
 
124
141
  const transaction = Transaction.deserialize(
125
142
  payload.transaction as Transaction.TransactionSerializedTempo,
@@ -224,6 +241,7 @@ describe.runIf(isPrecompileTestnet)('TIP20EscrowChannel precompile chain operati
224
241
  chain.id,
225
242
  )
226
243
  if (topUp.action !== 'topUp') throw new Error('expected topUp payload')
244
+ expectExpiringNonce(topUp.transaction)
227
245
 
228
246
  const result = await Chain.broadcastTopUpTransaction({
229
247
  additionalDeposit,
@@ -12,7 +12,7 @@ import {
12
12
  type Hex,
13
13
  } from 'viem'
14
14
  import { privateKeyToAccount } from 'viem/accounts'
15
- import { Transaction } from 'viem/tempo'
15
+ import { Abis, Addresses, Transaction } from 'viem/tempo'
16
16
  import { describe, expect, test } from 'vp/test'
17
17
 
18
18
  import { VerificationFailedError } from '../../../Errors.js'
@@ -36,6 +36,7 @@ const descriptor = {
36
36
  const deposit = Types.uint96(1_000_000n)
37
37
  const chainId = 42431
38
38
  const txHash = `0x${'ab'.repeat(32)}` as const
39
+ const sourceToken = '0x6666666666666666666666666666666666666666' as const
39
40
  const feePayer = privateKeyToAccount(
40
41
  '0x59c6995e998f97a5a0044966f0945389d1fc6e60e7346d6c36c49d32f75b9a1b',
41
42
  )
@@ -162,7 +163,11 @@ function topUpLog(parameters: { channelId: `0x${string}`; newDeposit: bigint })
162
163
  }
163
164
 
164
165
  async function createSerializedTransaction(parameters: {
165
- calls: { to: `0x${string}`; data?: `0x${string}` | undefined }[]
166
+ calls: {
167
+ to?: `0x${string}` | undefined
168
+ data?: `0x${string}` | undefined
169
+ value?: bigint | undefined
170
+ }[]
166
171
  gas?: bigint | undefined
167
172
  signed?: boolean | undefined
168
173
  }) {
@@ -192,6 +197,28 @@ async function createSerializedTransaction(parameters: {
192
197
  } as never)) as `0x${string}`
193
198
  }
194
199
 
200
+ function autoSwapCalls(amountOut: bigint = deposit) {
201
+ const maxAmountIn = amountOut + 1_000n
202
+ return [
203
+ {
204
+ to: sourceToken,
205
+ data: encodeFunctionData({
206
+ abi: Abis.tip20,
207
+ functionName: 'approve',
208
+ args: [Addresses.stablecoinDex, maxAmountIn],
209
+ }),
210
+ },
211
+ {
212
+ to: Addresses.stablecoinDex,
213
+ data: encodeFunctionData({
214
+ abi: Abis.stablecoinDex,
215
+ functionName: 'swapExactAmountOut',
216
+ args: [sourceToken, descriptor.token, amountOut, maxAmountIn],
217
+ }),
218
+ },
219
+ ] as const
220
+ }
221
+
195
222
  async function createOpenTransaction(
196
223
  parameters: {
197
224
  authorizedSigner?: `0x${string}` | undefined
@@ -201,6 +228,13 @@ async function createOpenTransaction(
201
228
  signed?: boolean | undefined
202
229
  token?: `0x${string}` | undefined
203
230
  to?: `0x${string}` | undefined
231
+ prefixCalls?:
232
+ | readonly {
233
+ to?: `0x${string}` | undefined
234
+ data?: `0x${string}` | undefined
235
+ value?: bigint | undefined
236
+ }[]
237
+ | undefined
204
238
  } = {},
205
239
  ) {
206
240
  const data = encodeFunctionData({
@@ -216,7 +250,7 @@ async function createOpenTransaction(
216
250
  ],
217
251
  })
218
252
  return createSerializedTransaction({
219
- calls: [{ to: parameters.to ?? tip20ChannelEscrow, data }],
253
+ calls: [...(parameters.prefixCalls ?? []), { to: parameters.to ?? tip20ChannelEscrow, data }],
220
254
  gas: parameters.gas,
221
255
  signed: parameters.signed,
222
256
  })
@@ -229,6 +263,13 @@ async function createTopUpTransaction(
229
263
  gas?: bigint | undefined
230
264
  signed?: boolean | undefined
231
265
  to?: `0x${string}` | undefined
266
+ prefixCalls?:
267
+ | readonly {
268
+ to?: `0x${string}` | undefined
269
+ data?: `0x${string}` | undefined
270
+ value?: bigint | undefined
271
+ }[]
272
+ | undefined
232
273
  } = {},
233
274
  ) {
234
275
  const data = encodeFunctionData({
@@ -240,7 +281,7 @@ async function createTopUpTransaction(
240
281
  ],
241
282
  })
242
283
  return createSerializedTransaction({
243
- calls: [{ to: parameters.to ?? tip20ChannelEscrow, data }],
284
+ calls: [...(parameters.prefixCalls ?? []), { to: parameters.to ?? tip20ChannelEscrow, data }],
244
285
  gas: parameters.gas,
245
286
  signed: parameters.signed,
246
287
  })
@@ -446,8 +487,152 @@ describe('precompile broadcastOpenTransaction', () => {
446
487
  expectedPayer: descriptor.payer,
447
488
  serializedTransaction,
448
489
  }),
449
- ).rejects.toThrow('TIP-1034 open transaction must contain exactly one call')
490
+ ).rejects.toThrow(
491
+ 'TIP-1034 open transaction must contain one management call, optionally preceded by an auto-swap',
492
+ )
493
+ })
494
+
495
+ const [validApprove, validSwap] = autoSwapCalls()
496
+ const wrongToken = '0x7777777777777777777777777777777777777777' as const
497
+ const maxAmountIn = deposit + 1_000n
498
+ const approve = (parameters: {
499
+ amount?: bigint | undefined
500
+ spender?: Address | undefined
501
+ token?: Address | undefined
502
+ }) => ({
503
+ to: parameters.token ?? sourceToken,
504
+ data: encodeFunctionData({
505
+ abi: Abis.tip20,
506
+ functionName: 'approve',
507
+ args: [parameters.spender ?? Addresses.stablecoinDex, parameters.amount ?? maxAmountIn],
508
+ }),
509
+ })
510
+ const swap = (parameters: {
511
+ amountOut?: bigint | undefined
512
+ maxAmount?: bigint | undefined
513
+ tokenIn?: Address | undefined
514
+ tokenOut?: Address | undefined
515
+ }) => ({
516
+ to: Addresses.stablecoinDex as Address,
517
+ data: encodeFunctionData({
518
+ abi: Abis.stablecoinDex,
519
+ functionName: 'swapExactAmountOut',
520
+ args: [
521
+ parameters.tokenIn ?? sourceToken,
522
+ parameters.tokenOut ?? descriptor.token,
523
+ parameters.amountOut ?? deposit,
524
+ parameters.maxAmount ?? maxAmountIn,
525
+ ],
526
+ }),
450
527
  })
528
+ const invalidAutoSwapPrefixes = [
529
+ {
530
+ name: 'missing call target',
531
+ prefixCalls: [{ data: validApprove.data }, validSwap],
532
+ reason: 'call is missing a target or calldata',
533
+ },
534
+ {
535
+ name: 'native value',
536
+ prefixCalls: [{ ...validApprove, value: 1n }, validSwap],
537
+ reason: 'calls must not transfer native value',
538
+ },
539
+ {
540
+ name: 'wrong DEX target',
541
+ prefixCalls: [validApprove, { ...validSwap, to: wrongToken }],
542
+ reason: 'targets the wrong DEX',
543
+ },
544
+ {
545
+ name: 'invalid approval calldata',
546
+ prefixCalls: [{ ...validApprove, data: '0x12345678' as const }, validSwap],
547
+ reason: 'approval calldata is invalid',
548
+ },
549
+ {
550
+ name: 'invalid swap calldata',
551
+ prefixCalls: [validApprove, { ...validSwap, data: '0x12345678' as const }],
552
+ reason: 'swap calldata is invalid',
553
+ },
554
+ {
555
+ name: 'wrong approval function',
556
+ prefixCalls: [
557
+ {
558
+ to: sourceToken,
559
+ data: encodeFunctionData({
560
+ abi: Abis.tip20,
561
+ functionName: 'transfer',
562
+ args: [Addresses.stablecoinDex, maxAmountIn],
563
+ }),
564
+ },
565
+ validSwap,
566
+ ],
567
+ reason: 'must contain approve followed by swapExactAmountOut',
568
+ },
569
+ {
570
+ name: 'approval token mismatch',
571
+ prefixCalls: [approve({ token: wrongToken }), validSwap],
572
+ reason: 'approval token does not match swap input',
573
+ },
574
+ {
575
+ name: 'approval spender mismatch',
576
+ prefixCalls: [approve({ spender: wrongToken }), validSwap],
577
+ reason: 'approval spender is not the DEX',
578
+ },
579
+ {
580
+ name: 'approval amount mismatch',
581
+ prefixCalls: [approve({ amount: maxAmountIn + 1n }), validSwap],
582
+ reason: 'approval amount does not match swap maximum input',
583
+ },
584
+ {
585
+ name: 'output token mismatch',
586
+ prefixCalls: [validApprove, swap({ tokenOut: wrongToken })],
587
+ reason: 'output token does not match channel currency',
588
+ },
589
+ {
590
+ name: 'output amount mismatch',
591
+ prefixCalls: [
592
+ approve({ amount: maxAmountIn + 1n }),
593
+ swap({ amountOut: deposit + 1n, maxAmount: maxAmountIn + 1n }),
594
+ ],
595
+ reason: 'output amount does not match channel deposit',
596
+ },
597
+ {
598
+ name: 'identical input and output tokens',
599
+ prefixCalls: [approve({ token: descriptor.token }), swap({ tokenIn: descriptor.token })],
600
+ reason: 'input and output tokens must differ',
601
+ },
602
+ {
603
+ name: 'non-canonical approval calldata',
604
+ prefixCalls: [{ ...validApprove, data: `${validApprove.data}00` as Hex }, validSwap],
605
+ reason: 'approval calldata is not canonical',
606
+ },
607
+ {
608
+ name: 'non-canonical swap calldata',
609
+ prefixCalls: [validApprove, { ...validSwap, data: `${validSwap.data}00` as Hex }],
610
+ reason: 'swap calldata is not canonical',
611
+ },
612
+ ] as const
613
+
614
+ test.each(invalidAutoSwapPrefixes)(
615
+ 'rejects an auto-swap with $name',
616
+ async ({ prefixCalls, reason }) => {
617
+ const serializedTransaction = await createOpenTransaction({ prefixCalls })
618
+
619
+ await expect(
620
+ Chain.broadcastOpenTransaction({
621
+ chainId,
622
+ client: createMockClient(),
623
+ escrowContract: tip20ChannelEscrow,
624
+ expectedAuthorizedSigner: descriptor.authorizedSigner,
625
+ expectedChannelId: `0x${'11'.repeat(32)}`,
626
+ expectedCurrency: descriptor.token,
627
+ expectedExpiringNonceHash: expectedExpiringNonceHash(serializedTransaction),
628
+ expectedOperator: descriptor.operator,
629
+ expectedPayee: descriptor.payee,
630
+ expectedPayer: descriptor.payer,
631
+ serializedTransaction,
632
+ }),
633
+ ).rejects.toThrow(`TIP-1034 open auto-swap ${reason}`)
634
+ },
635
+ )
451
636
 
452
637
  test('rejects open transactions targeting the wrong escrow contract', async () => {
453
638
  const serializedTransaction = await createOpenTransaction({ to: descriptor.token })
@@ -715,8 +900,8 @@ describe('precompile broadcastOpenTransaction', () => {
715
900
  ).rejects.toThrow('credential expiringNonceHash does not match transaction')
716
901
  })
717
902
 
718
- test('returns tx hash, descriptor, event fields, and read-back state on success', async () => {
719
- const serializedTransaction = await createOpenTransaction()
903
+ test('accepts an exact-output auto-swap before open', async () => {
904
+ const serializedTransaction = await createOpenTransaction({ prefixCalls: autoSwapCalls() })
720
905
  const expiringNonceHash = expectedExpiringNonceHash(serializedTransaction)
721
906
  const expectedDescriptor = { ...descriptor, expiringNonceHash }
722
907
  const channelId = Channel.computeId({
@@ -858,7 +1043,9 @@ describe('precompile broadcastTopUpTransaction', () => {
858
1043
  expectedCurrency: descriptor.token,
859
1044
  serializedTransaction,
860
1045
  }),
861
- ).rejects.toThrow('TIP-1034 topUp transaction must contain exactly one call')
1046
+ ).rejects.toThrow(
1047
+ 'TIP-1034 topUp transaction must contain one management call, optionally preceded by an auto-swap',
1048
+ )
862
1049
  })
863
1050
 
864
1051
  test('rejects top-up transactions targeting the wrong escrow contract', async () => {
@@ -1025,8 +1212,8 @@ describe('precompile broadcastTopUpTransaction', () => {
1025
1212
  ).rejects.toThrow('topUp deposit does not match credential')
1026
1213
  })
1027
1214
 
1028
- test('returns tx hash, new deposit, and read-back state on success', async () => {
1029
- const serializedTransaction = await createTopUpTransaction()
1215
+ test('accepts an exact-output auto-swap before top-up', async () => {
1216
+ const serializedTransaction = await createTopUpTransaction({ prefixCalls: autoSwapCalls() })
1030
1217
  const channelId = Channel.computeId({ ...descriptor, chainId, escrow: tip20ChannelEscrow })
1031
1218
  const newDeposit = deposit * 2n
1032
1219
  const state = { settled: 0n, deposit: newDeposit, closeRequestedAt: 0 }