mppx 0.5.13 → 0.5.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.
@@ -12,6 +12,13 @@ import * as Selectors from './selectors.js'
12
12
 
13
13
  const details = { amount: '1', currency: '0x01', recipient: '0x02' }
14
14
  const bogus = '0x0000000000000000000000000000000000000001' as const
15
+ const swapTokenIn = '0x0000000000000000000000000000000000000003' as const
16
+ const swapTokenOut = '0x0000000000000000000000000000000000000004' as const
17
+ const swapData = encodeFunctionData({
18
+ abi: Abis.stablecoinDex,
19
+ functionName: 'swapExactAmountOut',
20
+ args: [swapTokenIn, swapTokenOut, 100n, 100n],
21
+ })
15
22
  const sponsor = { address: bogus, type: 'local' } as any
16
23
 
17
24
  describe('callScopes', () => {
@@ -48,11 +55,11 @@ describe('validateCalls', () => {
48
55
  })
49
56
 
50
57
  test('accepts approve + buy + transfer', () => {
51
- const swapSelector = Selectors.swapExactAmountOut
52
58
  expect(() =>
53
59
  validateCalls(
54
60
  [
55
61
  {
62
+ to: swapTokenIn,
56
63
  data: encodeFunctionData({
57
64
  abi: Abis.tip20,
58
65
  functionName: 'approve',
@@ -61,7 +68,7 @@ describe('validateCalls', () => {
61
68
  },
62
69
  {
63
70
  to: Addresses.stablecoinDex,
64
- data: `${swapSelector}${'00'.repeat(128)}` as `0x${string}`,
71
+ data: swapData,
65
72
  },
66
73
  {
67
74
  data: encodeFunctionData({
@@ -77,11 +84,11 @@ describe('validateCalls', () => {
77
84
  })
78
85
 
79
86
  test('accepts multiple transfers after swap prefix', () => {
80
- const swapSelector = Selectors.swapExactAmountOut
81
87
  expect(() =>
82
88
  validateCalls(
83
89
  [
84
90
  {
91
+ to: swapTokenIn,
85
92
  data: encodeFunctionData({
86
93
  abi: Abis.tip20,
87
94
  functionName: 'approve',
@@ -90,7 +97,7 @@ describe('validateCalls', () => {
90
97
  },
91
98
  {
92
99
  to: Addresses.stablecoinDex,
93
- data: `${swapSelector}${'00'.repeat(128)}` as `0x${string}`,
100
+ data: swapData,
94
101
  },
95
102
  {
96
103
  data: encodeFunctionData({
@@ -142,7 +149,6 @@ describe('validateCalls', () => {
142
149
  })
143
150
 
144
151
  test('error: rejects wrong order (transfer before approve + buy)', () => {
145
- const swapSelector = Selectors.swapExactAmountOut
146
152
  expect(() =>
147
153
  validateCalls(
148
154
  [
@@ -154,6 +160,7 @@ describe('validateCalls', () => {
154
160
  }),
155
161
  },
156
162
  {
163
+ to: swapTokenIn,
157
164
  data: encodeFunctionData({
158
165
  abi: Abis.tip20,
159
166
  functionName: 'approve',
@@ -162,7 +169,7 @@ describe('validateCalls', () => {
162
169
  },
163
170
  {
164
171
  to: Addresses.stablecoinDex,
165
- data: `${swapSelector}${'00'.repeat(128)}` as `0x${string}`,
172
+ data: swapData,
166
173
  },
167
174
  ],
168
175
  details,
@@ -171,11 +178,11 @@ describe('validateCalls', () => {
171
178
  })
172
179
 
173
180
  test('error: rejects approve with non-DEX spender', () => {
174
- const swapSelector = Selectors.swapExactAmountOut
175
181
  expect(() =>
176
182
  validateCalls(
177
183
  [
178
184
  {
185
+ to: swapTokenIn,
179
186
  data: encodeFunctionData({
180
187
  abi: Abis.tip20,
181
188
  functionName: 'approve',
@@ -184,7 +191,7 @@ describe('validateCalls', () => {
184
191
  },
185
192
  {
186
193
  to: Addresses.stablecoinDex,
187
- data: `${swapSelector}${'00'.repeat(128)}` as `0x${string}`,
194
+ data: swapData,
188
195
  },
189
196
  {
190
197
  data: encodeFunctionData({
@@ -199,19 +206,48 @@ describe('validateCalls', () => {
199
206
  ).toThrow('approve spender is not the DEX')
200
207
  })
201
208
 
209
+ test('behavior: rejects approve targeting a non-token contract', () => {
210
+ expect(() =>
211
+ validateCalls(
212
+ [
213
+ {
214
+ to: bogus,
215
+ data: encodeFunctionData({
216
+ abi: Abis.tip20,
217
+ functionName: 'approve',
218
+ args: [Addresses.stablecoinDex, 100n],
219
+ }),
220
+ },
221
+ {
222
+ to: Addresses.stablecoinDex,
223
+ data: swapData,
224
+ },
225
+ {
226
+ data: encodeFunctionData({
227
+ abi: Abis.tip20,
228
+ functionName: 'transfer',
229
+ args: [bogus, 100n],
230
+ }),
231
+ },
232
+ ],
233
+ details,
234
+ ),
235
+ ).toThrow(FeePayerValidationError)
236
+ })
237
+
202
238
  test('error: rejects buy targeting non-DEX address', () => {
203
- const swapSelector = Selectors.swapExactAmountOut
204
239
  expect(() =>
205
240
  validateCalls(
206
241
  [
207
242
  {
243
+ to: swapTokenIn,
208
244
  data: encodeFunctionData({
209
245
  abi: Abis.tip20,
210
246
  functionName: 'approve',
211
247
  args: [Addresses.stablecoinDex, 100n],
212
248
  }),
213
249
  },
214
- { to: bogus, data: `${swapSelector}${'00'.repeat(128)}` as `0x${string}` },
250
+ { to: bogus, data: swapData },
215
251
  {
216
252
  data: encodeFunctionData({
217
253
  abi: Abis.tip20,
@@ -226,11 +262,11 @@ describe('validateCalls', () => {
226
262
  })
227
263
 
228
264
  test('error: rejects approve + buy without transfer', () => {
229
- const swapSelector = Selectors.swapExactAmountOut
230
265
  expect(() =>
231
266
  validateCalls(
232
267
  [
233
268
  {
269
+ to: swapTokenIn,
234
270
  data: encodeFunctionData({
235
271
  abi: Abis.tip20,
236
272
  functionName: 'approve',
@@ -239,7 +275,7 @@ describe('validateCalls', () => {
239
275
  },
240
276
  {
241
277
  to: Addresses.stablecoinDex,
242
- data: `${swapSelector}${'00'.repeat(128)}` as `0x${string}`,
278
+ data: swapData,
243
279
  },
244
280
  ],
245
281
  details,
@@ -98,14 +98,25 @@ export function validateCalls(
98
98
  throw new FeePayerValidationError('disallowed call pattern in fee-payer transaction', details)
99
99
  }
100
100
 
101
- // Validate approve spender and buy target are the DEX.
101
+ // Bind the swap approval to the token the DEX call will actually spend.
102
+ const buyCall = calls.find((c) => c.data?.slice(0, 10) === Selectors.swapExactAmountOut)
103
+ const buyArgs = buyCall
104
+ ? (decodeFunctionData({ abi: Abis.stablecoinDex, data: buyCall.data! }).args as [
105
+ `0x${string}`,
106
+ `0x${string}`,
107
+ bigint,
108
+ bigint,
109
+ ])
110
+ : undefined
111
+
102
112
  const approveCall = calls.find((c) => c.data?.slice(0, 10) === Selectors.approve)
103
113
  if (approveCall) {
104
114
  const { args } = decodeFunctionData({ abi: Abis.tip20, data: approveCall.data! })
115
+ if (!approveCall.to || (buyArgs && !TempoAddress_internal.isEqual(approveCall.to, buyArgs[0])))
116
+ throw new FeePayerValidationError('approve target does not match swap tokenIn', details)
105
117
  if (!TempoAddress_internal.isEqual((args as [`0x${string}`])[0]!, Addresses.stablecoinDex))
106
118
  throw new FeePayerValidationError('approve spender is not the DEX', details)
107
119
  }
108
- const buyCall = calls.find((c) => c.data?.slice(0, 10) === Selectors.swapExactAmountOut)
109
120
  if (
110
121
  buyCall &&
111
122
  (!buyCall.to || !TempoAddress_internal.isEqual(buyCall.to, Addresses.stablecoinDex))
@@ -1504,6 +1504,79 @@ describe('tempo', () => {
1504
1504
  httpServer.close()
1505
1505
  })
1506
1506
 
1507
+ test('behavior: fee payer simulates before broadcasting in confirmation mode', async () => {
1508
+ const rpcMethods: string[] = []
1509
+ const interceptingClient = createClient({
1510
+ account: accounts[0],
1511
+ chain: client.chain,
1512
+ transport: custom({
1513
+ async request(args: any) {
1514
+ rpcMethods.push(args.method)
1515
+ return client.transport.request(args)
1516
+ },
1517
+ }),
1518
+ })
1519
+
1520
+ const serverWithRpcTrace = Mppx_server.create({
1521
+ methods: [
1522
+ tempo_server.charge({
1523
+ getClient() {
1524
+ return interceptingClient
1525
+ },
1526
+ currency: asset,
1527
+ account: accounts[0],
1528
+ }),
1529
+ ],
1530
+ realm,
1531
+ secretKey,
1532
+ })
1533
+
1534
+ const mppx = Mppx_client.create({
1535
+ polyfill: false,
1536
+ methods: [
1537
+ tempo_client({
1538
+ account: accounts[1],
1539
+ getClient() {
1540
+ return client
1541
+ },
1542
+ }),
1543
+ ],
1544
+ })
1545
+
1546
+ const httpServer = await Http.createServer(async (req, res) => {
1547
+ const result = await Mppx_server.toNodeListener(
1548
+ serverWithRpcTrace.charge({
1549
+ feePayer: accounts[0],
1550
+ amount: '1',
1551
+ currency: asset,
1552
+ recipient: accounts[0].address,
1553
+ }),
1554
+ )(req, res)
1555
+ if (result.status === 402) return
1556
+ res.end('OK')
1557
+ })
1558
+
1559
+ const challengeResponse = await fetch(httpServer.url)
1560
+ expect(challengeResponse.status).toBe(402)
1561
+
1562
+ const credential = await mppx.createCredential(challengeResponse)
1563
+ rpcMethods.length = 0
1564
+
1565
+ const authResponse = await fetch(httpServer.url, {
1566
+ headers: { Authorization: credential },
1567
+ })
1568
+ expect(authResponse.status).toBe(200)
1569
+
1570
+ const broadcastIndex = rpcMethods.indexOf('eth_sendRawTransactionSync')
1571
+ const simulationIndex = rpcMethods.indexOf('eth_call')
1572
+
1573
+ expect(broadcastIndex).toBeGreaterThan(-1)
1574
+ expect(simulationIndex).toBeGreaterThan(-1)
1575
+ expect(simulationIndex).toBeLessThan(broadcastIndex)
1576
+
1577
+ httpServer.close()
1578
+ })
1579
+
1507
1580
  test('behavior: fee payer with splits', async () => {
1508
1581
  const mppx = Mppx_client.create({
1509
1582
  polyfill: false,
@@ -326,6 +326,12 @@ export function charge<const parameters extends charge.Parameters>(
326
326
  })()
327
327
 
328
328
  if (waitForConfirmation) {
329
+ await viem_call(client, {
330
+ ...transaction,
331
+ account: transaction.from,
332
+ feeToken: resolvedFeeToken,
333
+ calls: transaction.calls,
334
+ } as never)
329
335
  const receipt = await sendRawTransactionSync(client, {
330
336
  serializedTransaction: serializedTransaction_final,
331
337
  })
@@ -149,6 +149,40 @@ describe.runIf(isLocalnet)('session', () => {
149
149
  expect(ch!.highestVoucherAmount).toBe(1000000n)
150
150
  })
151
151
 
152
+ test('fee-payer policy override is enforced for sponsored open', async () => {
153
+ const salt = nextSalt()
154
+ const { channelId, serializedTransaction } = await signOpenChannel({
155
+ escrow: escrowContract,
156
+ payer,
157
+ payee: recipient,
158
+ token: currency,
159
+ deposit: 10000000n,
160
+ salt,
161
+ feePayer: true,
162
+ })
163
+ const server = createServer({
164
+ feePayer: recipientAccount,
165
+ feePayerPolicy: { maxGas: 1n },
166
+ })
167
+
168
+ await expect(
169
+ server.verify({
170
+ credential: {
171
+ challenge: makeChallenge({ channelId }),
172
+ payload: {
173
+ action: 'open' as const,
174
+ type: 'transaction' as const,
175
+ channelId,
176
+ transaction: serializedTransaction,
177
+ cumulativeAmount: '1000000',
178
+ signature: await signTestVoucher(channelId, 1000000n),
179
+ },
180
+ },
181
+ request: makeRequest({ feePayer: true }),
182
+ }),
183
+ ).rejects.toThrow('gas exceeds sponsor policy')
184
+ })
185
+
152
186
  test('rejects open when payee mismatch', async () => {
153
187
  const wrongPayee = accounts[3].address
154
188
  const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n, {
@@ -299,6 +333,66 @@ describe.runIf(isLocalnet)('session', () => {
299
333
  expect(ch!.highestVoucherAmount).toBe(1000000n)
300
334
  })
301
335
 
336
+ test('reopen with a case-variant channelId does not reset available balance', async () => {
337
+ let open:
338
+ | {
339
+ channelId: Hex
340
+ serializedTransaction: Hex
341
+ }
342
+ | undefined
343
+ for (let i = 0; i < 10; i++) {
344
+ const candidate = await createSignedOpenTransaction(10000000n)
345
+ if (/[a-f]/.test(candidate.channelId)) {
346
+ open = candidate
347
+ break
348
+ }
349
+ }
350
+ if (!open) throw new Error('failed to generate channelId with alphabetic hex characters')
351
+
352
+ const { channelId, serializedTransaction } = open
353
+ const caseVariantChannelId = channelId.replace(/[a-f]/, (character) =>
354
+ character.toUpperCase(),
355
+ ) as Hex
356
+ const server = createServer()
357
+
358
+ await server.verify({
359
+ credential: {
360
+ challenge: makeChallenge({ id: 'open-1', channelId }),
361
+ payload: {
362
+ action: 'open' as const,
363
+ type: 'transaction' as const,
364
+ channelId,
365
+ transaction: serializedTransaction,
366
+ cumulativeAmount: '5000000',
367
+ signature: await signTestVoucher(channelId, 5000000n),
368
+ },
369
+ },
370
+ request: makeRequest(),
371
+ })
372
+
373
+ await charge(store, channelId, 4000000n)
374
+
375
+ const reopenReceipt = (await server.verify({
376
+ credential: {
377
+ challenge: makeChallenge({ id: 'open-2', channelId: caseVariantChannelId }),
378
+ payload: {
379
+ action: 'open' as const,
380
+ type: 'transaction' as const,
381
+ channelId: caseVariantChannelId,
382
+ transaction: serializedTransaction,
383
+ cumulativeAmount: '5000000',
384
+ signature: await signTestVoucher(caseVariantChannelId, 5000000n),
385
+ },
386
+ },
387
+ request: makeRequest(),
388
+ })) as SessionReceipt
389
+
390
+ expect(reopenReceipt.spent).toBe('4000000')
391
+ await expect(charge(store, caseVariantChannelId, 2000000n)).rejects.toThrow(
392
+ 'requested 2000000, available 1000000',
393
+ )
394
+ })
395
+
302
396
  test('rejects voucher below settledOnChain', async () => {
303
397
  const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
304
398
  const server = createServer()
@@ -1036,6 +1130,40 @@ describe.runIf(isLocalnet)('session', () => {
1036
1130
  expect(ch!.deposit).toBe(20000000n)
1037
1131
  })
1038
1132
 
1133
+ test('fee-payer policy override is enforced for sponsored topUp', async () => {
1134
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
1135
+ const server = createServer({
1136
+ feePayer: recipientAccount,
1137
+ feePayerPolicy: { maxGas: 1n },
1138
+ })
1139
+ await openServerChannel(server, channelId, serializedTransaction)
1140
+
1141
+ const { serializedTransaction: topUpTx } = await signTopUpChannel({
1142
+ escrow: escrowContract,
1143
+ payer,
1144
+ channelId,
1145
+ token: currency,
1146
+ amount: 10000000n,
1147
+ feePayer: true,
1148
+ })
1149
+
1150
+ await expect(
1151
+ server.verify({
1152
+ credential: {
1153
+ challenge: makeChallenge({ id: 'challenge-topup-policy', channelId }),
1154
+ payload: {
1155
+ action: 'topUp' as const,
1156
+ type: 'transaction' as const,
1157
+ channelId,
1158
+ transaction: topUpTx,
1159
+ additionalDeposit: '10000000',
1160
+ },
1161
+ },
1162
+ request: makeRequest({ feePayer: true }),
1163
+ }),
1164
+ ).rejects.toThrow('gas exceeds sponsor policy')
1165
+ })
1166
+
1039
1167
  test('topUp receipt preserves spent and units from prior charges', async () => {
1040
1168
  const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
1041
1169
  const server = createServer()
@@ -4514,7 +4642,7 @@ function makeChallenge(opts: { id?: string; channelId: Hex }) {
4514
4642
  } as Challenge.Challenge<z.output<typeof Methods.session.schema.request>, 'session', 'tempo'>
4515
4643
  }
4516
4644
 
4517
- function makeRequest() {
4645
+ function makeRequest(overrides: Partial<Record<string, unknown>> = {}) {
4518
4646
  return {
4519
4647
  amount: '1000000',
4520
4648
  unitType: 'token',
@@ -4523,6 +4651,7 @@ function makeRequest() {
4523
4651
  recipient: recipient as string,
4524
4652
  escrowContract: escrowContract as string,
4525
4653
  chainId: chain.id,
4654
+ ...overrides,
4526
4655
  }
4527
4656
  }
4528
4657