mppx 0.6.12 → 0.6.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 (65) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/cli/plugins/tempo.d.ts.map +1 -1
  3. package/dist/cli/plugins/tempo.js +24 -1
  4. package/dist/cli/plugins/tempo.js.map +1 -1
  5. package/dist/middlewares/express.d.ts.map +1 -1
  6. package/dist/middlewares/express.js +22 -0
  7. package/dist/middlewares/express.js.map +1 -1
  8. package/dist/tempo/client/SessionManager.d.ts.map +1 -1
  9. package/dist/tempo/client/SessionManager.js +26 -1
  10. package/dist/tempo/client/SessionManager.js.map +1 -1
  11. package/dist/tempo/internal/fee-payer.d.ts +11 -1
  12. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  13. package/dist/tempo/internal/fee-payer.js +71 -6
  14. package/dist/tempo/internal/fee-payer.js.map +1 -1
  15. package/dist/tempo/server/Charge.d.ts.map +1 -1
  16. package/dist/tempo/server/Charge.js +53 -10
  17. package/dist/tempo/server/Charge.js.map +1 -1
  18. package/dist/tempo/server/Session.d.ts.map +1 -1
  19. package/dist/tempo/server/Session.js +80 -29
  20. package/dist/tempo/server/Session.js.map +1 -1
  21. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  22. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  23. package/dist/tempo/server/internal/html.gen.js +1 -1
  24. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  25. package/dist/tempo/server/internal/request-body.d.ts +1 -1
  26. package/dist/tempo/server/internal/request-body.d.ts.map +1 -1
  27. package/dist/tempo/server/internal/request-body.js +3 -0
  28. package/dist/tempo/server/internal/request-body.js.map +1 -1
  29. package/dist/tempo/server/internal/transport.d.ts +7 -0
  30. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  31. package/dist/tempo/server/internal/transport.js +16 -0
  32. package/dist/tempo/server/internal/transport.js.map +1 -1
  33. package/dist/tempo/session/Chain.d.ts +1 -0
  34. package/dist/tempo/session/Chain.d.ts.map +1 -1
  35. package/dist/tempo/session/Chain.js +28 -11
  36. package/dist/tempo/session/Chain.js.map +1 -1
  37. package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
  38. package/dist/tempo/session/ChannelStore.js +6 -0
  39. package/dist/tempo/session/ChannelStore.js.map +1 -1
  40. package/dist/tempo/session/Sse.d.ts +1 -0
  41. package/dist/tempo/session/Sse.d.ts.map +1 -1
  42. package/dist/tempo/session/Sse.js +34 -12
  43. package/dist/tempo/session/Sse.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/cli/plugins/tempo.ts +28 -1
  46. package/src/middlewares/express.test.ts +27 -0
  47. package/src/middlewares/express.ts +24 -0
  48. package/src/tempo/client/SessionManager.ts +26 -1
  49. package/src/tempo/internal/fee-payer.test.ts +139 -0
  50. package/src/tempo/internal/fee-payer.ts +85 -6
  51. package/src/tempo/server/Charge.test.ts +119 -0
  52. package/src/tempo/server/Charge.ts +70 -10
  53. package/src/tempo/server/Session.test.ts +327 -0
  54. package/src/tempo/server/Session.ts +91 -39
  55. package/src/tempo/server/internal/html.gen.ts +1 -1
  56. package/src/tempo/server/internal/request-body.test.ts +26 -0
  57. package/src/tempo/server/internal/request-body.ts +4 -1
  58. package/src/tempo/server/internal/transport.test.ts +28 -2
  59. package/src/tempo/server/internal/transport.ts +23 -0
  60. package/src/tempo/session/Chain.test.ts +140 -1
  61. package/src/tempo/session/Chain.ts +34 -10
  62. package/src/tempo/session/ChannelStore.test.ts +21 -0
  63. package/src/tempo/session/ChannelStore.ts +6 -0
  64. package/src/tempo/session/Sse.test.ts +52 -0
  65. package/src/tempo/session/Sse.ts +22 -2
@@ -247,6 +247,7 @@ export function tempo() {
247
247
  const channelId = parsed.payload.channelId
248
248
  const escrowContract = sessionMd?.escrowContract as Address | undefined
249
249
  const chainId = sessionMd?.chainId ?? 0
250
+ const tickCost = BigInt(challengeRequest.amount as string)
250
251
  let cumulativeAmount =
251
252
  'cumulativeAmount' in parsed.payload && parsed.payload.cumulativeAmount
252
253
  ? BigInt(parsed.payload.cumulativeAmount)
@@ -287,11 +288,11 @@ export function tempo() {
287
288
  if (receiptHeader) {
288
289
  try {
289
290
  const receiptJson = JSON.parse(Base64.toString(receiptHeader)) as Record<string, unknown>
291
+ assertReceiptWithinCliState(receiptJson, cumulativeAmount)
290
292
  if (
291
293
  typeof receiptJson.acceptedCumulative === 'string' &&
292
294
  receiptJson.acceptedCumulative
293
295
  ) {
294
- cumulativeAmount = BigInt(receiptJson.acceptedCumulative)
295
296
  writeChannelCumulative(channelId, cumulativeAmount)
296
297
  }
297
298
  if (verbose >= 1)
@@ -315,6 +316,7 @@ export function tempo() {
315
316
  escrowContract,
316
317
  chainId,
317
318
  cumulativeAmount,
319
+ tickCost,
318
320
  fetchUrl,
319
321
  fetchInit,
320
322
  session: _session,
@@ -415,6 +417,24 @@ function printReceipt(
415
417
  if (opts.prefix) opts.info('\n')
416
418
  }
417
419
 
420
+ function assertReceiptWithinCliState(
421
+ receiptJson: Record<string, unknown>,
422
+ cumulativeAmount: bigint,
423
+ ) {
424
+ if (typeof receiptJson.acceptedCumulative !== 'string') return
425
+ const acceptedCumulative = BigInt(receiptJson.acceptedCumulative)
426
+ const spent = typeof receiptJson.spent === 'string' ? BigInt(receiptJson.spent) : 0n
427
+ if (spent > acceptedCumulative) {
428
+ throw new Error('receipt spent exceeds accepted cumulative voucher amount')
429
+ }
430
+ if (acceptedCumulative > cumulativeAmount) {
431
+ throw new Error('receipt accepted cumulative exceeds locally signed voucher amount')
432
+ }
433
+ if (spent > cumulativeAmount) {
434
+ throw new Error('receipt spent exceeds locally signed voucher amount')
435
+ }
436
+ }
437
+
418
438
  async function handleSseStream(
419
439
  response: Response,
420
440
  opts: {
@@ -423,6 +443,7 @@ async function handleSseStream(
423
443
  escrowContract: Address | undefined
424
444
  chainId: number
425
445
  cumulativeAmount: bigint
446
+ tickCost: bigint
426
447
  fetchUrl: string
427
448
  fetchInit: RequestInit
428
449
  session: {
@@ -500,7 +521,13 @@ async function handleSseStream(
500
521
  channelId: string
501
522
  requiredCumulative: string
502
523
  }
524
+ if (event.channelId !== opts.channelId) {
525
+ throw new Error('payment-need-voucher channelId does not match current session')
526
+ }
503
527
  const required = BigInt(event.requiredCumulative)
528
+ if (required > cumulativeAmount + opts.tickCost) {
529
+ throw new Error('payment-need-voucher exceeds next locally billable amount')
530
+ }
504
531
  cumulativeAmount = cumulativeAmount > required ? cumulativeAmount : required
505
532
 
506
533
  const voucherCred = await opts.session.signVoucher({
@@ -253,6 +253,33 @@ describe('session', () => {
253
253
  })
254
254
 
255
255
  describe('payment', () => {
256
+ test('short-circuits management responses', async () => {
257
+ let handlerRan = false
258
+ const managementResponse = new Response(null, {
259
+ status: 204,
260
+ headers: { 'Payment-Receipt': 'management-receipt' },
261
+ })
262
+ const intent = () => async () => ({
263
+ status: 200 as const,
264
+ withReceipt: () => managementResponse,
265
+ })
266
+
267
+ const app = express()
268
+ app.get('/', payment(intent as any, {} as any), (_req, res) => {
269
+ handlerRan = true
270
+ res.json({ data: 'content' })
271
+ })
272
+
273
+ const server = await createServer(app)
274
+ const response = await globalThis.fetch(server.url)
275
+ expect(response.status).toBe(204)
276
+ expect(response.headers.get('Payment-Receipt')).toBe('management-receipt')
277
+ expect(await response.text()).toBe('')
278
+ expect(handlerRan).toBe(false)
279
+
280
+ server.close()
281
+ })
282
+
256
283
  test('returns 402 when no credential', async () => {
257
284
  const { mppx } = createCoreChargeHarness(false)
258
285
 
@@ -76,6 +76,30 @@ export function payment<const intent extends Mppx_internal.AnyMethodFn>(
76
76
  return
77
77
  }
78
78
 
79
+ const managementResponse = (() => {
80
+ try {
81
+ return (result.withReceipt as () => Response)()
82
+ } catch (error) {
83
+ if (
84
+ error instanceof Error &&
85
+ error.message === 'withReceipt() requires a response argument'
86
+ )
87
+ return null
88
+ throw error
89
+ }
90
+ })()
91
+
92
+ if (managementResponse) {
93
+ res.status(managementResponse.status)
94
+ for (const [key, value] of managementResponse.headers) res.setHeader(key, value)
95
+ if (managementResponse.body === null) {
96
+ res.end()
97
+ return
98
+ }
99
+ res.send(Buffer.from(await managementResponse.arrayBuffer()))
100
+ return
101
+ }
102
+
79
103
  const originalJson = res.json.bind(res)
80
104
  res.json = (body: any) => {
81
105
  const wrapped = result.withReceipt(Response.json(body))
@@ -138,10 +138,28 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
138
138
 
139
139
  function updateSpentFromReceipt(receipt: SessionReceipt | null | undefined) {
140
140
  if (!receipt || receipt.channelId !== channel?.channelId) return
141
+ assertReceiptWithinLocalState(receipt)
141
142
  const next = BigInt(receipt.spent)
142
143
  spent = spent > next ? spent : next
143
144
  }
144
145
 
146
+ function assertReceiptWithinLocalState(receipt: SessionReceipt) {
147
+ if (!channel || receipt.channelId !== channel.channelId) return
148
+ const acceptedCumulative = BigInt(receipt.acceptedCumulative)
149
+ const receiptSpent = BigInt(receipt.spent)
150
+ if (receiptSpent > acceptedCumulative) {
151
+ throw new Error('receipt spent exceeds accepted cumulative voucher amount')
152
+ }
153
+ if (acceptedCumulative > channel.cumulativeAmount) {
154
+ throw new Error('receipt accepted cumulative exceeds local voucher state')
155
+ }
156
+ if (receiptSpent > channel.cumulativeAmount) {
157
+ throw new Error('receipt spent exceeds local voucher state')
158
+ }
159
+ assertVoucherWithinLocalLimit(acceptedCumulative)
160
+ assertVoucherWithinLocalLimit(receiptSpent)
161
+ }
162
+
145
163
  function waitForReceipt(predicate: (receipt: SessionReceipt) => boolean = () => true) {
146
164
  if (receiptWaiter) throw new Error('receipt wait already in progress')
147
165
  return new Promise<SessionReceipt>((resolve, reject) => {
@@ -762,7 +780,14 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
762
780
  context: {
763
781
  action: 'close',
764
782
  channelId: closeChannelId,
765
- cumulativeAmountRaw: getFallbackCloseAmount(closeChallenge, closeChannelId),
783
+ cumulativeAmountRaw: (() => {
784
+ const closeAmount = BigInt(getFallbackCloseAmount(closeChallenge, closeChannelId))
785
+ if (closeAmount > channel.cumulativeAmount) {
786
+ throw new Error('fallback close amount exceeds local voucher state')
787
+ }
788
+ assertVoucherWithinLocalLimit(closeAmount)
789
+ return closeAmount.toString()
790
+ })(),
766
791
  },
767
792
  })
768
793
 
@@ -123,6 +123,145 @@ describe('validateCalls', () => {
123
123
  ).not.toThrow()
124
124
  })
125
125
 
126
+ test('accepts approve + buy + exact expected split transfers', () => {
127
+ expect(() =>
128
+ validateCalls(
129
+ [
130
+ {
131
+ to: swapTokenIn,
132
+ data: encodeFunctionData({
133
+ abi: Abis.tip20,
134
+ functionName: 'approve',
135
+ args: [Addresses.stablecoinDex, 100n],
136
+ }),
137
+ },
138
+ {
139
+ to: Addresses.stablecoinDex,
140
+ data: swapData,
141
+ },
142
+ {
143
+ to: swapTokenOut,
144
+ data: encodeFunctionData({
145
+ abi: Abis.tip20,
146
+ functionName: 'transfer',
147
+ args: [bogus, 90n],
148
+ }),
149
+ },
150
+ {
151
+ to: swapTokenOut,
152
+ data: encodeFunctionData({
153
+ abi: Abis.tip20,
154
+ functionName: 'transferWithMemo',
155
+ args: [
156
+ '0x0000000000000000000000000000000000000002',
157
+ 10n,
158
+ '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
159
+ ],
160
+ }),
161
+ },
162
+ ],
163
+ details,
164
+ {
165
+ currency: swapTokenOut,
166
+ expectedTransfers: [
167
+ { amount: '90', recipient: bogus },
168
+ {
169
+ amount: '10',
170
+ memo: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
171
+ recipient: '0x0000000000000000000000000000000000000002',
172
+ },
173
+ ],
174
+ },
175
+ ),
176
+ ).not.toThrow()
177
+ })
178
+
179
+ test('error: rejects extra transfers when expected payments are supplied', () => {
180
+ expect(() =>
181
+ validateCalls(
182
+ [
183
+ {
184
+ to: swapTokenOut,
185
+ data: encodeFunctionData({
186
+ abi: Abis.tip20,
187
+ functionName: 'transfer',
188
+ args: [bogus, 100n],
189
+ }),
190
+ },
191
+ {
192
+ to: swapTokenOut,
193
+ data: encodeFunctionData({
194
+ abi: Abis.tip20,
195
+ functionName: 'transfer',
196
+ args: ['0x0000000000000000000000000000000000000002', 1n],
197
+ }),
198
+ },
199
+ ],
200
+ details,
201
+ {
202
+ currency: swapTokenOut,
203
+ expectedTransfers: [{ amount: '100', recipient: bogus }],
204
+ },
205
+ ),
206
+ ).toThrow('disallowed call pattern')
207
+ })
208
+
209
+ test('error: rejects expected transfers to the wrong token', () => {
210
+ expect(() =>
211
+ validateCalls(
212
+ [
213
+ {
214
+ to: swapTokenIn,
215
+ data: encodeFunctionData({
216
+ abi: Abis.tip20,
217
+ functionName: 'transfer',
218
+ args: [bogus, 100n],
219
+ }),
220
+ },
221
+ ],
222
+ details,
223
+ {
224
+ currency: swapTokenOut,
225
+ expectedTransfers: [{ amount: '100', recipient: bogus }],
226
+ },
227
+ ),
228
+ ).toThrow('payment transfer does not match challenge')
229
+ })
230
+
231
+ test('error: rejects swaps whose output token does not fund the payment', () => {
232
+ expect(() =>
233
+ validateCalls(
234
+ [
235
+ {
236
+ to: swapTokenIn,
237
+ data: encodeFunctionData({
238
+ abi: Abis.tip20,
239
+ functionName: 'approve',
240
+ args: [Addresses.stablecoinDex, 100n],
241
+ }),
242
+ },
243
+ {
244
+ to: Addresses.stablecoinDex,
245
+ data: swapData,
246
+ },
247
+ {
248
+ to: bogus,
249
+ data: encodeFunctionData({
250
+ abi: Abis.tip20,
251
+ functionName: 'transfer',
252
+ args: [bogus, 100n],
253
+ }),
254
+ },
255
+ ],
256
+ details,
257
+ {
258
+ currency: bogus,
259
+ expectedTransfers: [{ amount: '100', recipient: bogus }],
260
+ },
261
+ ),
262
+ ).toThrow('swap tokenOut does not match payment currency')
263
+ })
264
+
126
265
  test('error: rejects empty calls', () => {
127
266
  expect(() => validateCalls([], details)).toThrow(FeePayerValidationError)
128
267
  })
@@ -1,5 +1,6 @@
1
1
  import type { TempoAddress } from 'ox/tempo'
2
2
  import { TxEnvelopeTempo } from 'ox/tempo'
3
+ import type { Hex } from 'viem'
3
4
  import type { Account } from 'viem'
4
5
  import { decodeFunctionData } from 'viem'
5
6
  import { Abis, Addresses, Transaction } from 'viem/tempo'
@@ -42,6 +43,13 @@ export type Policy = {
42
43
  // Tempo transaction fields.
43
44
  type SponsoredTransaction = ReturnType<(typeof Transaction)['deserialize']>
44
45
 
46
+ type ExpectedTransfer = {
47
+ amount: string
48
+ allowAnyMemo?: boolean | undefined
49
+ memo?: Hex | undefined
50
+ recipient: TempoAddress.Address
51
+ }
52
+
45
53
  const preservedTransactionKeys = [
46
54
  'accessList',
47
55
  'calls',
@@ -122,6 +130,10 @@ function getPolicy(chainId: number, overrides: Partial<Policy> | undefined): Pol
122
130
  export function validateCalls(
123
131
  calls: readonly { data?: `0x${string}` | undefined; to?: TempoAddress.Address | undefined }[],
124
132
  details: Record<string, string>,
133
+ options?: {
134
+ currency?: TempoAddress.Address | undefined
135
+ expectedTransfers?: readonly ExpectedTransfer[] | undefined
136
+ },
125
137
  ) {
126
138
  if (calls.length === 0)
127
139
  throw new FeePayerValidationError('disallowed call pattern in fee-payer transaction', details)
@@ -137,15 +149,19 @@ export function validateCalls(
137
149
  }
138
150
 
139
151
  const transferSelectors = callSelectors.slice(hasSwapPrefix ? 2 : 0)
152
+ if (transferSelectors.length === 0)
153
+ throw new FeePayerValidationError('disallowed call pattern in fee-payer transaction', details)
154
+
155
+ const expectedTransfers = options?.expectedTransfers
156
+ const transferLimit = expectedTransfers?.length ?? 11
140
157
  if (
141
- transferSelectors.length === 0 ||
142
- transferSelectors.length > 11 ||
158
+ transferSelectors.length > transferLimit ||
143
159
  transferSelectors.some(
144
160
  (selector) => selector !== Selectors.transfer && selector !== Selectors.transferWithMemo,
145
- )
146
- ) {
161
+ ) ||
162
+ (expectedTransfers && transferSelectors.length !== expectedTransfers.length)
163
+ )
147
164
  throw new FeePayerValidationError('disallowed call pattern in fee-payer transaction', details)
148
- }
149
165
 
150
166
  // Bind the swap approval to the token the DEX call will actually spend.
151
167
  const buyCall = calls.find((c) => c.data?.slice(0, 10) === Selectors.swapExactAmountOut)
@@ -161,16 +177,79 @@ export function validateCalls(
161
177
  const approveCall = calls.find((c) => c.data?.slice(0, 10) === Selectors.approve)
162
178
  if (approveCall) {
163
179
  const { args } = decodeFunctionData({ abi: Abis.tip20, data: approveCall.data! })
180
+ const [spender, amount] = args as [`0x${string}`, bigint]
164
181
  if (!approveCall.to || (buyArgs && !TempoAddress_internal.isEqual(approveCall.to, buyArgs[0])))
165
182
  throw new FeePayerValidationError('approve target does not match swap tokenIn', details)
166
- if (!TempoAddress_internal.isEqual((args as [`0x${string}`])[0]!, Addresses.stablecoinDex))
183
+ if (!TempoAddress_internal.isEqual(spender, Addresses.stablecoinDex))
167
184
  throw new FeePayerValidationError('approve spender is not the DEX', details)
185
+ if (buyArgs && amount !== buyArgs[3])
186
+ throw new FeePayerValidationError('approve amount does not match swap max input', details)
168
187
  }
169
188
  if (
170
189
  buyCall &&
171
190
  (!buyCall.to || !TempoAddress_internal.isEqual(buyCall.to, Addresses.stablecoinDex))
172
191
  )
173
192
  throw new FeePayerValidationError('buy target is not the DEX', details)
193
+
194
+ if (!expectedTransfers) return
195
+
196
+ const currency = options?.currency ?? (details.currency as TempoAddress.Address | undefined)
197
+ if (!currency) throw new FeePayerValidationError('missing payment currency', details)
198
+
199
+ if (buyArgs) {
200
+ const [, tokenOut, amountOut] = buyArgs
201
+ const expectedAmountOut = expectedTransfers.reduce(
202
+ (sum, transfer) => sum + BigInt(transfer.amount),
203
+ 0n,
204
+ )
205
+ if (!TempoAddress_internal.isEqual(tokenOut, currency))
206
+ throw new FeePayerValidationError('swap tokenOut does not match payment currency', details)
207
+ if (amountOut !== expectedAmountOut)
208
+ throw new FeePayerValidationError('swap output does not match payment amount', details)
209
+ }
210
+
211
+ const transferCalls = calls.slice(hasSwapPrefix ? 2 : 0)
212
+ const sorted = [...expectedTransfers].sort((a, b) => {
213
+ if (a.memo && !b.memo) return -1
214
+ if (!a.memo && b.memo) return 1
215
+ return 0
216
+ })
217
+
218
+ const used = new Set<number>()
219
+ for (const expected of sorted) {
220
+ const matchIndex = transferCalls.findIndex((call, index) => {
221
+ if (used.has(index)) return false
222
+ if (!call.to || !TempoAddress_internal.isEqual(call.to, currency) || !call.data) return false
223
+
224
+ try {
225
+ const selector = call.data.slice(0, 10)
226
+ const { args } = decodeFunctionData({ abi: Abis.tip20, data: call.data })
227
+ if (selector === Selectors.transfer) {
228
+ const [recipient, amount] = args as [`0x${string}`, bigint]
229
+ if (!TempoAddress_internal.isEqual(recipient, expected.recipient)) return false
230
+ if (amount.toString() !== expected.amount) return false
231
+ return expected.memo === undefined
232
+ }
233
+
234
+ if (selector === Selectors.transferWithMemo) {
235
+ const [recipient, amount, memo] = args as [`0x${string}`, bigint, Hex]
236
+ if (!TempoAddress_internal.isEqual(recipient, expected.recipient)) return false
237
+ if (amount.toString() !== expected.amount) return false
238
+ if (expected.memo) return memo.toLowerCase() === expected.memo.toLowerCase()
239
+ return expected.allowAnyMemo === true
240
+ }
241
+ } catch {
242
+ return false
243
+ }
244
+
245
+ return false
246
+ })
247
+
248
+ if (matchIndex === -1)
249
+ throw new FeePayerValidationError('payment transfer does not match challenge', details)
250
+
251
+ used.add(matchIndex)
252
+ }
174
253
  }
175
254
 
176
255
  export function prepareSponsoredTransaction(parameters: {
@@ -1579,6 +1579,104 @@ describe('tempo', () => {
1579
1579
  httpServer.close()
1580
1580
  })
1581
1581
 
1582
+ test('behavior: fee payer rejects concurrent in-flight transactions from one sender', async () => {
1583
+ let releaseSimulation!: () => void
1584
+ let resolveSimulationStarted!: () => void
1585
+ const simulationStarted = new Promise<void>((resolve) => {
1586
+ resolveSimulationStarted = resolve
1587
+ })
1588
+ const releaseSimulationPromise = new Promise<void>((resolve) => {
1589
+ releaseSimulation = resolve
1590
+ })
1591
+ let heldFirstSimulation = false
1592
+
1593
+ const interceptingClient = createClient({
1594
+ account: accounts[0],
1595
+ chain: client.chain,
1596
+ transport: custom({
1597
+ async request(args: any) {
1598
+ if (args.method === 'eth_call' && !heldFirstSimulation) {
1599
+ heldFirstSimulation = true
1600
+ resolveSimulationStarted()
1601
+ await releaseSimulationPromise
1602
+ }
1603
+ return client.transport.request(args)
1604
+ },
1605
+ }),
1606
+ })
1607
+
1608
+ const sponsoredStore = Store.memory()
1609
+ const serverWithSlowSimulation = Mppx_server.create({
1610
+ methods: [
1611
+ tempo_server.charge({
1612
+ getClient() {
1613
+ return interceptingClient
1614
+ },
1615
+ currency: asset,
1616
+ account: accounts[0],
1617
+ store: sponsoredStore,
1618
+ }),
1619
+ ],
1620
+ realm,
1621
+ secretKey,
1622
+ })
1623
+
1624
+ const mppx = Mppx_client.create({
1625
+ polyfill: false,
1626
+ methods: [
1627
+ tempo_client({
1628
+ account: accounts[1],
1629
+ getClient() {
1630
+ return client
1631
+ },
1632
+ }),
1633
+ ],
1634
+ })
1635
+
1636
+ const httpServer = await Http.createServer(async (req, res) => {
1637
+ const result = await Mppx_server.toNodeListener(
1638
+ serverWithSlowSimulation.charge({
1639
+ feePayer: accounts[0],
1640
+ amount: '1',
1641
+ currency: asset,
1642
+ recipient: accounts[0].address,
1643
+ }),
1644
+ )(req, res)
1645
+ if (result.status === 402) return
1646
+ res.end('OK')
1647
+ })
1648
+
1649
+ const [challengeResponse1, challengeResponse2] = await Promise.all([
1650
+ fetch(httpServer.url),
1651
+ fetch(httpServer.url),
1652
+ ])
1653
+ expect(challengeResponse1.status).toBe(402)
1654
+ expect(challengeResponse2.status).toBe(402)
1655
+
1656
+ const [credential1, credential2] = await Promise.all([
1657
+ mppx.createCredential(challengeResponse1),
1658
+ mppx.createCredential(challengeResponse2),
1659
+ ])
1660
+
1661
+ const first = fetch(httpServer.url, { headers: { Authorization: credential1 } })
1662
+ await simulationStarted
1663
+ const second = fetch(httpServer.url, { headers: { Authorization: credential2 } })
1664
+
1665
+ try {
1666
+ const secondResponse = await second
1667
+ expect(secondResponse.status).toBe(402)
1668
+ const body = (await secondResponse.json()) as { detail: string }
1669
+ expect(body.detail).toContain('Sponsored transaction from this sender is already in flight')
1670
+ } finally {
1671
+ releaseSimulation()
1672
+ }
1673
+
1674
+ const firstResponse = await first
1675
+ expect(firstResponse.status).toBe(200)
1676
+
1677
+ httpServer.close()
1678
+ })
1679
+
1582
1680
  test('behavior: fee payer with splits', async () => {
1583
1681
  const mppx = Mppx_client.create({
1584
1682
  polyfill: false,
@@ -3338,6 +3436,27 @@ describe('tempo', () => {
3338
3436
  })
3339
3437
  expect(method.defaults?.recipient).toBe(accounts[0].address)
3340
3438
  })
3439
+
3440
+ test('request keeps explicit feePayer false disabled during verification', async () => {
3441
+ const method = tempo_server.charge({
3442
+ getClient: () => client,
3443
+ account: accounts[0],
3444
+ currency: asset,
3445
+ feePayer: accounts[0],
3446
+ })
3447
+
3448
+ const normalized = await method.request!({
3449
+ credential: { challenge: {}, payload: {} } as never,
3450
+ request: {
3451
+ amount: '1',
3452
+ currency: asset,
3453
+ decimals: 6,
3454
+ feePayer: false,
3455
+ },
3456
+ } as never)
3457
+
3458
+ expect(normalized.feePayer).toBe(false)
3459
+ })
3341
3460
  })
3342
3461
 
3343
3462
  describe('default currency resolution', () => {