mppx 0.4.10 → 0.4.12

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 (74) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/dist/internal/env.d.ts +1 -1
  3. package/dist/internal/env.d.ts.map +1 -1
  4. package/dist/internal/env.js +2 -6
  5. package/dist/internal/env.js.map +1 -1
  6. package/dist/internal/types.d.ts +23 -0
  7. package/dist/internal/types.d.ts.map +1 -1
  8. package/dist/server/Mppx.d.ts +1 -1
  9. package/dist/server/Mppx.d.ts.map +1 -1
  10. package/dist/server/Mppx.js +49 -2
  11. package/dist/server/Mppx.js.map +1 -1
  12. package/dist/stripe/internal/types.d.ts +3 -0
  13. package/dist/stripe/internal/types.d.ts.map +1 -1
  14. package/dist/stripe/server/Charge.d.ts.map +1 -1
  15. package/dist/stripe/server/Charge.js +9 -2
  16. package/dist/stripe/server/Charge.js.map +1 -1
  17. package/dist/tempo/Methods.d.ts +15 -0
  18. package/dist/tempo/Methods.d.ts.map +1 -1
  19. package/dist/tempo/Methods.js +27 -3
  20. package/dist/tempo/Methods.js.map +1 -1
  21. package/dist/tempo/client/Charge.d.ts +21 -0
  22. package/dist/tempo/client/Charge.d.ts.map +1 -1
  23. package/dist/tempo/client/Charge.js +33 -7
  24. package/dist/tempo/client/Charge.js.map +1 -1
  25. package/dist/tempo/client/Methods.d.ts +15 -0
  26. package/dist/tempo/client/Methods.d.ts.map +1 -1
  27. package/dist/tempo/internal/account.d.ts +5 -11
  28. package/dist/tempo/internal/account.d.ts.map +1 -1
  29. package/dist/tempo/internal/charge.d.ts +20 -0
  30. package/dist/tempo/internal/charge.d.ts.map +1 -0
  31. package/dist/tempo/internal/charge.js +23 -0
  32. package/dist/tempo/internal/charge.js.map +1 -0
  33. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  34. package/dist/tempo/internal/fee-payer.js +15 -3
  35. package/dist/tempo/internal/fee-payer.js.map +1 -1
  36. package/dist/tempo/server/Charge.d.ts +17 -2
  37. package/dist/tempo/server/Charge.d.ts.map +1 -1
  38. package/dist/tempo/server/Charge.js +148 -99
  39. package/dist/tempo/server/Charge.js.map +1 -1
  40. package/dist/tempo/server/Methods.d.ts +17 -2
  41. package/dist/tempo/server/Methods.d.ts.map +1 -1
  42. package/dist/tempo/server/Methods.js +4 -1
  43. package/dist/tempo/server/Methods.js.map +1 -1
  44. package/dist/tempo/server/Session.d.ts +9 -4
  45. package/dist/tempo/server/Session.d.ts.map +1 -1
  46. package/dist/tempo/server/Session.js +25 -6
  47. package/dist/tempo/server/Session.js.map +1 -1
  48. package/dist/tempo/session/Chain.d.ts +18 -2
  49. package/dist/tempo/session/Chain.d.ts.map +1 -1
  50. package/dist/tempo/session/Chain.js +18 -14
  51. package/dist/tempo/session/Chain.js.map +1 -1
  52. package/package.json +1 -1
  53. package/src/internal/env.test.ts +12 -12
  54. package/src/internal/env.ts +2 -6
  55. package/src/internal/types.ts +25 -0
  56. package/src/server/Mppx.test.ts +287 -0
  57. package/src/server/Mppx.ts +59 -5
  58. package/src/stripe/internal/types.ts +5 -1
  59. package/src/stripe/server/Charge.test.ts +52 -1
  60. package/src/stripe/server/Charge.ts +12 -4
  61. package/src/tempo/Methods.test.ts +79 -0
  62. package/src/tempo/Methods.ts +53 -17
  63. package/src/tempo/client/Charge.ts +41 -8
  64. package/src/tempo/internal/account.ts +7 -14
  65. package/src/tempo/internal/charge.ts +43 -0
  66. package/src/tempo/internal/fee-payer.test.ts +33 -14
  67. package/src/tempo/internal/fee-payer.ts +21 -6
  68. package/src/tempo/server/Charge.test.ts +231 -0
  69. package/src/tempo/server/Charge.ts +193 -124
  70. package/src/tempo/server/Methods.ts +4 -1
  71. package/src/tempo/server/Session.test.ts +57 -0
  72. package/src/tempo/server/Session.ts +33 -20
  73. package/src/tempo/session/Chain.test.ts +25 -5
  74. package/src/tempo/session/Chain.ts +30 -14
@@ -1,9 +1,18 @@
1
- import type { Account } from 'viem'
1
+ import type { Account, Address } from 'viem'
2
2
  import { parseUnits } from 'viem'
3
3
 
4
4
  import * as Method from '../Method.js'
5
5
  import * as z from '../zod.js'
6
6
 
7
+ const split = z.object({
8
+ amount: z.amount(),
9
+ memo: z.optional(z.hash()),
10
+ recipient: z.pipe(
11
+ z.string(),
12
+ z.transform((v) => v as Address),
13
+ ),
14
+ })
15
+
7
16
  /**
8
17
  * Tempo charge intent for one-time TIP-20 token transfers.
9
18
  *
@@ -20,31 +29,58 @@ export const charge = Method.from({
20
29
  ]),
21
30
  },
22
31
  request: z.pipe(
23
- z.object({
24
- amount: z.amount(),
25
- chainId: z.optional(z.number()),
26
- currency: z.string(),
27
- decimals: z.number(),
28
- description: z.optional(z.string()),
29
- externalId: z.optional(z.string()),
30
- feePayer: z.optional(
31
- z.pipe(
32
- z.union([z.boolean(), z.custom<Account>()]),
33
- z.transform((v): boolean => (typeof v === 'object' ? true : v)),
32
+ z
33
+ .object({
34
+ amount: z.amount(),
35
+ chainId: z.optional(z.number()),
36
+ currency: z.string(),
37
+ decimals: z.number(),
38
+ description: z.optional(z.string()),
39
+ externalId: z.optional(z.string()),
40
+ feePayer: z.optional(
41
+ z.pipe(
42
+ z.union([z.boolean(), z.custom<Account>()]),
43
+ z.transform((v): boolean => (typeof v === 'object' ? true : v)),
44
+ ),
34
45
  ),
46
+ memo: z.optional(z.hash()),
47
+ recipient: z.optional(z.string()),
48
+ splits: z.optional(z.array(split).check(z.minLength(1), z.maxLength(10))),
49
+ })
50
+ .check(
51
+ z.refine(({ amount, decimals, splits }) => {
52
+ if (!splits) return true
53
+
54
+ const totalAmount = parseUnits(amount, decimals)
55
+ const splitTotal = splits.reduce(
56
+ (sum, split) => sum + parseUnits(split.amount, decimals),
57
+ 0n,
58
+ )
59
+
60
+ return (
61
+ splits.every((split) => parseUnits(split.amount, decimals) > 0n) &&
62
+ splitTotal < totalAmount
63
+ )
64
+ }, 'Invalid splits'),
35
65
  ),
36
- memo: z.optional(z.hash()),
37
- recipient: z.optional(z.string()),
38
- }),
39
- z.transform(({ amount, chainId, decimals, feePayer, memo, ...rest }) => ({
66
+ z.transform(({ amount, chainId, decimals, feePayer, memo, splits, ...rest }) => ({
40
67
  ...rest,
41
68
  amount: parseUnits(amount, decimals).toString(),
42
- ...(chainId !== undefined || feePayer !== undefined || memo !== undefined
69
+ ...(chainId !== undefined ||
70
+ feePayer !== undefined ||
71
+ memo !== undefined ||
72
+ splits !== undefined
43
73
  ? {
44
74
  methodDetails: {
45
75
  ...(chainId !== undefined && { chainId }),
46
76
  ...(feePayer !== undefined && { feePayer }),
47
77
  ...(memo !== undefined && { memo }),
78
+ ...(splits !== undefined && {
79
+ splits: splits.map((split) => ({
80
+ ...split,
81
+ amount: parseUnits(split.amount, decimals).toString(),
82
+ })),
83
+ }),
48
84
  },
49
85
  }
50
86
  : {}),
@@ -11,6 +11,7 @@ import * as Client from '../../viem/Client.js'
11
11
  import * as z from '../../zod.js'
12
12
  import * as Attribution from '../Attribution.js'
13
13
  import * as AutoSwap from '../internal/auto-swap.js'
14
+ import * as Charge_internal from '../internal/charge.js'
14
15
  import * as defaults from '../internal/defaults.js'
15
16
  import * as Methods from '../Methods.js'
16
17
 
@@ -54,18 +55,37 @@ export function charge(parameters: charge.Parameters = {}) {
54
55
  const { request } = challenge
55
56
  const { amount, methodDetails } = request
56
57
  const currency = request.currency as Address
57
- const recipient = request.recipient as Address
58
+
59
+ if (parameters.expectedRecipients) {
60
+ const allowed = new Set(parameters.expectedRecipients.map((a) => a.toLowerCase()))
61
+ const splits = methodDetails?.splits as readonly { recipient: string }[] | undefined
62
+ if (splits) {
63
+ for (const split of splits) {
64
+ if (!allowed.has(split.recipient.toLowerCase()))
65
+ throw new Error(`Unexpected split recipient: ${split.recipient}`)
66
+ }
67
+ }
68
+ }
58
69
 
59
70
  const memo = methodDetails?.memo
60
71
  ? (methodDetails.memo as Hex.Hex)
61
72
  : Attribution.encode({ serverId: challenge.realm, clientId })
62
-
63
- const transferCall = Actions.token.transfer.call({
64
- amount: BigInt(amount),
65
- memo,
66
- to: recipient,
67
- token: currency,
73
+ const transfers = Charge_internal.getTransfers({
74
+ amount,
75
+ methodDetails: {
76
+ ...methodDetails,
77
+ memo,
78
+ },
79
+ recipient: request.recipient as Address,
68
80
  })
81
+ const transferCalls = transfers.map((transfer) =>
82
+ Actions.token.transfer.call({
83
+ amount: BigInt(transfer.amount),
84
+ ...(transfer.memo && { memo: transfer.memo as Hex.Hex }),
85
+ to: transfer.recipient as Address,
86
+ token: currency,
87
+ }),
88
+ )
69
89
 
70
90
  const autoSwap = AutoSwap.resolve(
71
91
  context?.autoSwap ?? parameters.autoSwap,
@@ -82,7 +102,14 @@ export function charge(parameters: charge.Parameters = {}) {
82
102
  })
83
103
  : undefined
84
104
 
85
- const calls = [...(swapCalls ?? []), transferCall]
105
+ const calls = [...(swapCalls ?? []), ...transferCalls]
106
+
107
+ const validBefore = (() => {
108
+ const defaultExpiry = Math.floor(Date.now() / 1000) + 25
109
+ if (!challenge.expires) return defaultExpiry
110
+ const challengeExpiry = Math.floor(new Date(challenge.expires).getTime() / 1000)
111
+ return Math.min(defaultExpiry, challengeExpiry)
112
+ })()
86
113
 
87
114
  if (mode === 'push') {
88
115
  const { receipts } = await sendCallsSync(client, {
@@ -104,6 +131,7 @@ export function charge(parameters: charge.Parameters = {}) {
104
131
  calls,
105
132
  ...(methodDetails?.feePayer && { feePayer: true }),
106
133
  nonceKey: 'expiring',
134
+ validBefore,
107
135
  } as never)
108
136
  // FIXME: figure out gas estimation issue for fee payer tx
109
137
  prepared.gas = prepared.gas! + 5_000n
@@ -131,6 +159,11 @@ export declare namespace charge {
131
159
  autoSwap?: AutoSwap | undefined
132
160
  /** Client identifier used to derive the client fingerprint in attribution memos. */
133
161
  clientId?: string | undefined
162
+ /**
163
+ * Allowlist of expected split recipient addresses. When set, the client
164
+ * rejects any challenge whose split recipients are not in this list.
165
+ */
166
+ expectedRecipients?: readonly Address[] | undefined
134
167
  /**
135
168
  * Controls how the charge transaction is submitted.
136
169
  *
@@ -33,18 +33,11 @@ export function resolve(parameters: resolve.Parameters) {
33
33
  }
34
34
 
35
35
  export declare namespace resolve {
36
- type Parameters = { recipient?: Address | undefined } & (
37
- | {
38
- /** Account that performs payment operations. */
39
- account?: Account | undefined
40
- /** When true, the account also sponsors (pays) transaction fees. */
41
- feePayer?: true | undefined
42
- }
43
- | {
44
- /** Address that receives payment. */
45
- account?: Address | undefined
46
- /** Optional fee payer account or fee payer URL for covering transaction fees. */
47
- feePayer?: Account | string | undefined
48
- }
49
- )
36
+ type Parameters = {
37
+ recipient?: Address | undefined
38
+ /** Account or address that performs payment operations / receives payment. */
39
+ account?: Account | Address | undefined
40
+ /** When `true`, the account also sponsors fees. An `Account` object or URL string can also be provided as a dedicated fee payer. */
41
+ feePayer?: Account | string | true | undefined
42
+ }
50
43
  }
@@ -0,0 +1,43 @@
1
+ import type { Address } from 'viem'
2
+
3
+ export type Split = {
4
+ amount: string
5
+ memo?: string | undefined
6
+ recipient: Address
7
+ }
8
+
9
+ export type Transfer = {
10
+ amount: string
11
+ memo?: string | undefined
12
+ recipient: Address
13
+ }
14
+
15
+ export function getTransfers(request: {
16
+ amount: string
17
+ methodDetails?: { memo?: string | undefined; splits?: readonly Split[] | undefined }
18
+ recipient: Address
19
+ }): Transfer[] {
20
+ const totalAmount = BigInt(request.amount)
21
+ const splits = request.methodDetails?.splits ?? []
22
+
23
+ const splitTotal = splits.reduce((sum, split) => sum + BigInt(split.amount), 0n)
24
+ if (splitTotal >= totalAmount)
25
+ throw new Error('Invalid charge request: split total must be less than total amount.')
26
+
27
+ const primaryAmount = totalAmount - splitTotal
28
+ if (primaryAmount <= 0n)
29
+ throw new Error('Invalid charge request: primary transfer amount must be positive.')
30
+
31
+ return [
32
+ {
33
+ amount: primaryAmount.toString(),
34
+ memo: request.methodDetails?.memo,
35
+ recipient: request.recipient,
36
+ },
37
+ ...splits.map((split) => ({
38
+ amount: split.amount,
39
+ memo: split.memo,
40
+ recipient: split.recipient,
41
+ })),
42
+ ]
43
+ }
@@ -70,17 +70,7 @@ describe('validateCalls', () => {
70
70
  ).not.toThrow()
71
71
  })
72
72
 
73
- test('error: rejects empty calls', () => {
74
- expect(() => validateCalls([], details)).toThrow(FeePayerValidationError)
75
- })
76
-
77
- test('error: rejects unknown selector', () => {
78
- expect(() => validateCalls([{ data: '0xdeadbeef' as `0x${string}` }], details)).toThrow(
79
- 'disallowed call pattern',
80
- )
81
- })
82
-
83
- test('error: rejects extra calls beyond allowed patterns', () => {
73
+ test('accepts multiple transfers after swap prefix', () => {
84
74
  const swapSelector = Selectors.swapExactAmountOut
85
75
  expect(() =>
86
76
  validateCalls(
@@ -100,19 +90,48 @@ describe('validateCalls', () => {
100
90
  data: encodeFunctionData({
101
91
  abi: Abis.tip20,
102
92
  functionName: 'transfer',
103
- args: [bogus, 100n],
93
+ args: [bogus, 90n],
104
94
  }),
105
95
  },
106
96
  {
107
97
  data: encodeFunctionData({
108
98
  abi: Abis.tip20,
109
- functionName: 'transfer',
110
- args: [bogus, 100n],
99
+ functionName: 'transferWithMemo',
100
+ args: [
101
+ '0x0000000000000000000000000000000000000002',
102
+ 10n,
103
+ '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
104
+ ],
111
105
  }),
112
106
  },
113
107
  ],
114
108
  details,
115
109
  ),
110
+ ).not.toThrow()
111
+ })
112
+
113
+ test('error: rejects empty calls', () => {
114
+ expect(() => validateCalls([], details)).toThrow(FeePayerValidationError)
115
+ })
116
+
117
+ test('error: rejects unknown selector', () => {
118
+ expect(() => validateCalls([{ data: '0xdeadbeef' as `0x${string}` }], details)).toThrow(
119
+ 'disallowed call pattern',
120
+ )
121
+ })
122
+
123
+ test('error: rejects more than 11 transfers', () => {
124
+ expect(() =>
125
+ validateCalls(
126
+ Array.from({ length: 12 }, (_, index) => ({
127
+ data: encodeFunctionData({
128
+ abi: Abis.tip20,
129
+ functionName: 'transfer',
130
+ args: [`0x${(index + 1).toString(16).padStart(40, '0')}`, 100n],
131
+ }),
132
+ })),
133
+ details,
134
+ ),
116
135
  ).toThrow('disallowed call pattern')
117
136
  })
118
137
 
@@ -30,14 +30,29 @@ export function validateCalls(
30
30
  calls: readonly { data?: `0x${string}` | undefined; to?: TempoAddress.Address | undefined }[],
31
31
  details: Record<string, string>,
32
32
  ) {
33
+ if (calls.length === 0)
34
+ throw new FeePayerValidationError('disallowed call pattern in fee-payer transaction', details)
35
+
33
36
  const callSelectors = calls.map((c) => c.data?.slice(0, 10))
34
- const allowed = callScopes.some(
35
- (pattern) =>
36
- pattern.length === callSelectors.length &&
37
- pattern.every((sel, i) => sel === callSelectors[i]),
38
- )
39
- if (!allowed)
37
+ const hasSwapPrefix = callSelectors[0] === Selectors.approve
38
+
39
+ if (hasSwapPrefix) {
40
+ if (callSelectors[1] !== Selectors.swapExactAmountOut)
41
+ throw new FeePayerValidationError('disallowed call pattern in fee-payer transaction', details)
42
+ } else if (callSelectors[0] === Selectors.swapExactAmountOut) {
43
+ throw new FeePayerValidationError('disallowed call pattern in fee-payer transaction', details)
44
+ }
45
+
46
+ const transferSelectors = callSelectors.slice(hasSwapPrefix ? 2 : 0)
47
+ if (
48
+ transferSelectors.length === 0 ||
49
+ transferSelectors.length > 11 ||
50
+ transferSelectors.some(
51
+ (selector) => selector !== Selectors.transfer && selector !== Selectors.transferWithMemo,
52
+ )
53
+ ) {
40
54
  throw new FeePayerValidationError('disallowed call pattern in fee-payer transaction', details)
55
+ }
41
56
 
42
57
  // Validate approve spender and buy target are the DEX.
43
58
  const approveCall = calls.find((c) => c.data?.slice(0, 10) === Selectors.approve)
@@ -650,6 +650,107 @@ describe('tempo', () => {
650
650
  httpServer.close()
651
651
  })
652
652
 
653
+ test('behavior: accepts split payments', async () => {
654
+ const mppx = Mppx_client.create({
655
+ polyfill: false,
656
+ methods: [
657
+ tempo_client({
658
+ account: accounts[1],
659
+ getClient: () => client,
660
+ }),
661
+ ],
662
+ })
663
+
664
+ const httpServer = await Http.createServer(async (req, res) => {
665
+ const result = await Mppx_server.toNodeListener(
666
+ server.charge({
667
+ amount: '1',
668
+ currency: asset,
669
+ recipient: accounts[0].address,
670
+ splits: [
671
+ { amount: '0.2', recipient: accounts[2].address },
672
+ {
673
+ amount: '0.1',
674
+ memo: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
675
+ recipient: accounts[3].address,
676
+ },
677
+ ],
678
+ }),
679
+ )(req, res)
680
+ if (result.status === 402) return
681
+ res.end('OK')
682
+ })
683
+
684
+ const response = await mppx.fetch(httpServer.url)
685
+ expect(response.status).toBe(200)
686
+
687
+ httpServer.close()
688
+ })
689
+
690
+ test('behavior: accepts transaction when split transfers are out of order', async () => {
691
+ const httpServer = await Http.createServer(async (req, res) => {
692
+ const result = await Mppx_server.toNodeListener(
693
+ server.charge({
694
+ amount: '1',
695
+ currency: asset,
696
+ recipient: accounts[0].address,
697
+ splits: [
698
+ { amount: '0.2', recipient: accounts[2].address },
699
+ { amount: '0.1', recipient: accounts[3].address },
700
+ ],
701
+ }),
702
+ )(req, res)
703
+ if (result.status === 402) return
704
+ res.end('OK')
705
+ })
706
+
707
+ const response = await fetch(httpServer.url)
708
+ expect(response.status).toBe(402)
709
+
710
+ const challenge = Challenge.fromResponse(response, {
711
+ methods: [tempo_client.charge()],
712
+ })
713
+ const splits = challenge.request.methodDetails?.splits ?? []
714
+ const primaryAmount =
715
+ BigInt(challenge.request.amount) - BigInt(splits[0]!.amount) - BigInt(splits[1]!.amount)
716
+
717
+ const prepared = await prepareTransactionRequest(client, {
718
+ account: accounts[1]!,
719
+ calls: [
720
+ Actions.token.transfer.call({
721
+ amount: BigInt(splits[1]!.amount),
722
+ to: splits[1]!.recipient as Hex.Hex,
723
+ token: challenge.request.currency as Hex.Hex,
724
+ }),
725
+ Actions.token.transfer.call({
726
+ amount: primaryAmount,
727
+ to: challenge.request.recipient as Hex.Hex,
728
+ token: challenge.request.currency as Hex.Hex,
729
+ }),
730
+ Actions.token.transfer.call({
731
+ amount: BigInt(splits[0]!.amount),
732
+ to: splits[0]!.recipient as Hex.Hex,
733
+ token: challenge.request.currency as Hex.Hex,
734
+ }),
735
+ ],
736
+ nonceKey: 'expiring',
737
+ } as never)
738
+ prepared.gas = prepared.gas! + 5_000n
739
+ const signature = await signTransaction(client, prepared as never)
740
+
741
+ const credential = Credential.from({
742
+ challenge,
743
+ payload: { signature, type: 'transaction' as const },
744
+ })
745
+
746
+ const authResponse = await fetch(httpServer.url, {
747
+ headers: { Authorization: Credential.serialize(credential) },
748
+ })
749
+ expect(authResponse.status).toBe(200)
750
+
751
+ httpServer.close()
752
+ })
753
+
653
754
  test('behavior: rejects expired request', async () => {
654
755
  const httpServer = await Http.createServer(async (req, res) => {
655
756
  const result = await Mppx_server.toNodeListener(
@@ -1156,6 +1257,39 @@ describe('tempo', () => {
1156
1257
  httpServer.close()
1157
1258
  })
1158
1259
 
1260
+ test('behavior: fee payer with splits', async () => {
1261
+ const mppx = Mppx_client.create({
1262
+ polyfill: false,
1263
+ methods: [
1264
+ tempo_client({
1265
+ account: accounts[1],
1266
+ getClient() {
1267
+ return client
1268
+ },
1269
+ }),
1270
+ ],
1271
+ })
1272
+
1273
+ const httpServer = await Http.createServer(async (req, res) => {
1274
+ const result = await Mppx_server.toNodeListener(
1275
+ server.charge({
1276
+ feePayer: accounts[0],
1277
+ amount: '1',
1278
+ currency: asset,
1279
+ recipient: accounts[0].address,
1280
+ splits: [{ amount: '0.2', recipient: accounts[2].address }],
1281
+ }),
1282
+ )(req, res)
1283
+ if (result.status === 402) return
1284
+ res.end('OK')
1285
+ })
1286
+
1287
+ const response = await mppx.fetch(httpServer.url)
1288
+ expect(response.status).toBe(200)
1289
+
1290
+ httpServer.close()
1291
+ })
1292
+
1159
1293
  test('behavior: fee payer (hoisted)', async () => {
1160
1294
  const mppx = Mppx_client.create({
1161
1295
  polyfill: false,
@@ -1658,6 +1792,70 @@ describe('tempo', () => {
1658
1792
 
1659
1793
  httpServer.close()
1660
1794
  })
1795
+
1796
+ test('behavior: accepts split transaction when transfers are out of order', async () => {
1797
+ const httpServer = await Http.createServer(async (req, res) => {
1798
+ const result = await Mppx_server.toNodeListener(
1799
+ server.charge({
1800
+ amount: '1',
1801
+ currency: asset,
1802
+ recipient: accounts[0].address,
1803
+ splits: [
1804
+ { amount: '0.2', recipient: accounts[2].address },
1805
+ { amount: '0.1', recipient: accounts[3].address },
1806
+ ],
1807
+ }),
1808
+ )(req, res)
1809
+ if (result.status === 402) return
1810
+ res.end('OK')
1811
+ })
1812
+
1813
+ const response = await fetch(httpServer.url)
1814
+ expect(response.status).toBe(402)
1815
+
1816
+ const challenge = Challenge.fromResponse(response, {
1817
+ methods: [tempo_client.charge()],
1818
+ })
1819
+ const splits = challenge.request.methodDetails?.splits ?? []
1820
+ const primaryAmount =
1821
+ BigInt(challenge.request.amount) - BigInt(splits[0]!.amount) - BigInt(splits[1]!.amount)
1822
+
1823
+ const prepared = await prepareTransactionRequest(client, {
1824
+ account: accounts[1]!,
1825
+ calls: [
1826
+ Actions.token.transfer.call({
1827
+ amount: BigInt(splits[0]!.amount),
1828
+ to: splits[0]!.recipient as Hex.Hex,
1829
+ token: challenge.request.currency as Hex.Hex,
1830
+ }),
1831
+ Actions.token.transfer.call({
1832
+ amount: primaryAmount,
1833
+ to: challenge.request.recipient as Hex.Hex,
1834
+ token: challenge.request.currency as Hex.Hex,
1835
+ }),
1836
+ Actions.token.transfer.call({
1837
+ amount: BigInt(splits[1]!.amount),
1838
+ to: splits[1]!.recipient as Hex.Hex,
1839
+ token: challenge.request.currency as Hex.Hex,
1840
+ }),
1841
+ ],
1842
+ nonceKey: 'expiring',
1843
+ } as never)
1844
+ prepared.gas = prepared.gas! + 5_000n
1845
+ const signature = await signTransaction(client, prepared as never)
1846
+
1847
+ const credential = Credential.from({
1848
+ challenge,
1849
+ payload: { signature, type: 'transaction' as const },
1850
+ })
1851
+
1852
+ const authResponse = await fetch(httpServer.url, {
1853
+ headers: { Authorization: Credential.serialize(credential) },
1854
+ })
1855
+ expect(authResponse.status).toBe(200)
1856
+
1857
+ httpServer.close()
1858
+ })
1661
1859
  })
1662
1860
 
1663
1861
  describe('intent: charge; type: transaction; waitForConfirmation: false', () => {
@@ -2295,6 +2493,39 @@ describe('tempo', () => {
2295
2493
  httpServer.close()
2296
2494
  })
2297
2495
 
2496
+ test('swaps via DEX when user lacks target currency for split payments', async () => {
2497
+ const mppx = Mppx_client.create({
2498
+ polyfill: false,
2499
+ methods: [
2500
+ tempo_client({
2501
+ account: swapPayer,
2502
+ autoSwap: true,
2503
+ getClient() {
2504
+ return client
2505
+ },
2506
+ }),
2507
+ ],
2508
+ })
2509
+
2510
+ const httpServer = await Http.createServer(async (req, res) => {
2511
+ const result = await Mppx_server.toNodeListener(
2512
+ server.charge({
2513
+ amount: '1',
2514
+ currency: asset,
2515
+ recipient: accounts[0]!.address,
2516
+ splits: [{ amount: '0.2', recipient: accounts[2]!.address }],
2517
+ }),
2518
+ )(req, res)
2519
+ if (result.status === 402) return
2520
+ res.end('OK')
2521
+ })
2522
+
2523
+ const response = await mppx.fetch(httpServer.url)
2524
+ expect(response.status).toBe(200)
2525
+
2526
+ httpServer.close()
2527
+ })
2528
+
2298
2529
  test('custom slippage and tokenIn', async () => {
2299
2530
  const mppx = Mppx_client.create({
2300
2531
  polyfill: false,