mppx 0.6.13 → 0.6.15

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 (81) 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/client/Mppx.d.ts.map +1 -1
  6. package/dist/client/Mppx.js +3 -0
  7. package/dist/client/Mppx.js.map +1 -1
  8. package/dist/client/internal/Fetch.d.ts.map +1 -1
  9. package/dist/client/internal/Fetch.js +3 -0
  10. package/dist/client/internal/Fetch.js.map +1 -1
  11. package/dist/mcp-sdk/client/McpClient.d.ts.map +1 -1
  12. package/dist/mcp-sdk/client/McpClient.js +3 -0
  13. package/dist/mcp-sdk/client/McpClient.js.map +1 -1
  14. package/dist/middlewares/express.d.ts.map +1 -1
  15. package/dist/middlewares/express.js +22 -0
  16. package/dist/middlewares/express.js.map +1 -1
  17. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  18. package/dist/stripe/server/internal/html.gen.js +1 -1
  19. package/dist/tempo/client/SessionManager.d.ts.map +1 -1
  20. package/dist/tempo/client/SessionManager.js +26 -1
  21. package/dist/tempo/client/SessionManager.js.map +1 -1
  22. package/dist/tempo/internal/fee-payer.d.ts +11 -1
  23. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  24. package/dist/tempo/internal/fee-payer.js +71 -6
  25. package/dist/tempo/internal/fee-payer.js.map +1 -1
  26. package/dist/tempo/server/Charge.d.ts.map +1 -1
  27. package/dist/tempo/server/Charge.js +53 -10
  28. package/dist/tempo/server/Charge.js.map +1 -1
  29. package/dist/tempo/server/Session.d.ts.map +1 -1
  30. package/dist/tempo/server/Session.js +76 -29
  31. package/dist/tempo/server/Session.js.map +1 -1
  32. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  33. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  34. package/dist/tempo/server/internal/html.gen.js +1 -1
  35. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  36. package/dist/tempo/server/internal/request-body.d.ts +1 -1
  37. package/dist/tempo/server/internal/request-body.d.ts.map +1 -1
  38. package/dist/tempo/server/internal/request-body.js +3 -0
  39. package/dist/tempo/server/internal/request-body.js.map +1 -1
  40. package/dist/tempo/server/internal/transport.d.ts +7 -0
  41. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  42. package/dist/tempo/server/internal/transport.js +16 -0
  43. package/dist/tempo/server/internal/transport.js.map +1 -1
  44. package/dist/tempo/session/Chain.d.ts +1 -0
  45. package/dist/tempo/session/Chain.d.ts.map +1 -1
  46. package/dist/tempo/session/Chain.js +28 -11
  47. package/dist/tempo/session/Chain.js.map +1 -1
  48. package/dist/tempo/session/Sse.d.ts +1 -0
  49. package/dist/tempo/session/Sse.d.ts.map +1 -1
  50. package/dist/tempo/session/Sse.js +19 -12
  51. package/dist/tempo/session/Sse.js.map +1 -1
  52. package/package.json +3 -3
  53. package/src/cli/plugins/tempo.ts +28 -1
  54. package/src/client/Mppx.test.ts +39 -3
  55. package/src/client/Mppx.ts +2 -0
  56. package/src/client/internal/Fetch.test.ts +22 -3
  57. package/src/client/internal/Fetch.ts +2 -0
  58. package/src/mcp-sdk/client/McpClient.test.ts +39 -2
  59. package/src/mcp-sdk/client/McpClient.ts +3 -0
  60. package/src/middlewares/express.test.ts +27 -0
  61. package/src/middlewares/express.ts +24 -0
  62. package/src/stripe/server/internal/html/package.json +1 -1
  63. package/src/stripe/server/internal/html.gen.ts +1 -1
  64. package/src/tempo/client/SessionManager.ts +26 -1
  65. package/src/tempo/internal/fee-payer.test.ts +139 -0
  66. package/src/tempo/internal/fee-payer.ts +85 -6
  67. package/src/tempo/server/Charge.test.ts +119 -0
  68. package/src/tempo/server/Charge.ts +70 -10
  69. package/src/tempo/server/Session.test.ts +327 -0
  70. package/src/tempo/server/Session.ts +88 -39
  71. package/src/tempo/server/internal/html/main.ts +10 -3
  72. package/src/tempo/server/internal/html/package.json +1 -1
  73. package/src/tempo/server/internal/html.gen.ts +1 -1
  74. package/src/tempo/server/internal/request-body.test.ts +26 -0
  75. package/src/tempo/server/internal/request-body.ts +4 -1
  76. package/src/tempo/server/internal/transport.test.ts +28 -2
  77. package/src/tempo/server/internal/transport.ts +23 -0
  78. package/src/tempo/session/Chain.test.ts +140 -1
  79. package/src/tempo/session/Chain.ts +34 -10
  80. package/src/tempo/session/Sse.test.ts +25 -0
  81. package/src/tempo/session/Sse.ts +9 -2
@@ -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', () => {
@@ -138,9 +138,10 @@ export function charge<const parameters extends charge.Parameters>(
138
138
  throw new Error(`Client not configured with chainId ${chainId}.`)
139
139
 
140
140
  const resolvedFeePayer = (() => {
141
+ if (request.feePayer === false) return credential ? false : undefined
141
142
  const account = typeof request.feePayer === 'object' ? request.feePayer : feePayer
142
- const requested = request.feePayer !== false && (account ?? feePayer ?? feePayerUrl)
143
- if (credential) return account
143
+ const requested = account ?? feePayer ?? feePayerUrl
144
+ if (credential) return account ?? (feePayerUrl ? true : undefined)
144
145
  if (requested) return true
145
146
  return undefined
146
147
  })()
@@ -167,12 +168,17 @@ export function charge<const parameters extends charge.Parameters>(
167
168
  const client = await getClient({ chainId })
168
169
 
169
170
  const { amount, methodDetails } = resolvedRequest
171
+ const requestAllowsFeePayer =
172
+ request.feePayer !== false &&
173
+ (request.feePayer === undefined ||
174
+ request.feePayer === true ||
175
+ typeof request.feePayer === 'object')
170
176
  const feePayerAccount =
171
- typeof request.feePayer === 'object'
172
- ? request.feePayer
173
- : methodDetails?.feePayer === true
174
- ? feePayer
175
- : undefined
177
+ methodDetails?.feePayer === true && requestAllowsFeePayer
178
+ ? typeof request.feePayer === 'object'
179
+ ? request.feePayer
180
+ : feePayer
181
+ : undefined
176
182
  const expires = challenge.expires
177
183
  const supportedModes = methodDetails?.supportedModes as
178
184
  | readonly Methods.ChargeMode[]
@@ -292,6 +298,7 @@ export function charge<const parameters extends charge.Parameters>(
292
298
  }
293
299
 
294
300
  let releaseReservation = true
301
+ let sponsoredSenderReservation: { chainId: number; sender: `0x${string}` } | undefined
295
302
 
296
303
  try {
297
304
  if (!FeePayer.isTempoTransaction(serializedTransaction))
@@ -309,7 +316,10 @@ export function charge<const parameters extends charge.Parameters>(
309
316
  to?: `0x${string}` | undefined
310
317
  }[]
311
318
  const transfers = getExpectedTransfers({ amount, memo, methodDetails, recipient })
312
- const isFeePayerTx = !!(feePayer || feePayerUrl) && methodDetails?.feePayer !== false
319
+ const isFeePayerTx =
320
+ methodDetails?.feePayer === true &&
321
+ requestAllowsFeePayer &&
322
+ !!(feePayerAccount || feePayerUrl)
313
323
  const matchedCalls = assertTransferCalls(calls, {
314
324
  currency,
315
325
  exactCount: isFeePayerTx,
@@ -321,8 +331,28 @@ export function charge<const parameters extends charge.Parameters>(
321
331
  realm: challenge.realm,
322
332
  })
323
333
 
324
- if (isFeePayerTx)
325
- FeePayer.validateCalls(transaction.calls, { amount, currency, recipient })
334
+ if (isFeePayerTx) {
335
+ const reservationChainId = chainId ?? client.chain!.id
336
+ if (
337
+ !(await markSponsoredSenderInFlight(store, {
338
+ chainId: reservationChainId,
339
+ sender: transaction.from as `0x${string}`,
340
+ }))
341
+ ) {
342
+ throw new VerificationFailedError({
343
+ reason: 'Sponsored transaction from this sender is already in flight',
344
+ })
345
+ }
346
+ sponsoredSenderReservation = {
347
+ chainId: reservationChainId,
348
+ sender: transaction.from as `0x${string}`,
349
+ }
350
+ FeePayer.validateCalls(
351
+ transaction.calls,
352
+ { amount, currency, recipient },
353
+ { currency, expectedTransfers: transfers },
354
+ )
355
+ }
326
356
 
327
357
  const expectedFeeToken = defaults.currency[chainId as keyof typeof defaults.currency]
328
358
  const resolvedFeeToken = transaction.feeToken ?? expectedFeeToken
@@ -413,6 +443,9 @@ export function charge<const parameters extends charge.Parameters>(
413
443
  } catch (error) {
414
444
  if (releaseReservation) await releaseHashUse(store, hash)
415
445
  throw error
446
+ } finally {
447
+ if (sponsoredSenderReservation)
448
+ await releaseSponsoredSenderInFlight(store, sponsoredSenderReservation)
416
449
  }
417
450
  }
418
451
 
@@ -698,6 +731,14 @@ function getProofStoreKey(challengeId: string): `mppx:charge:${string}` {
698
731
  return `mppx:charge:proof:${challengeId}`
699
732
  }
700
733
 
734
+ /** @internal */
735
+ function getSponsoredSenderStoreKey(parameters: {
736
+ chainId: number
737
+ sender: `0x${string}`
738
+ }): `mppx:charge:${string}` {
739
+ return `mppx:charge:sponsor:${parameters.chainId}:${parameters.sender.toLowerCase()}`
740
+ }
741
+
701
742
  async function markHashUsed(
702
743
  store: Store.AtomicStore<charge.StoreItemMap>,
703
744
  hash: `0x${string}`,
@@ -716,6 +757,25 @@ async function releaseHashUse(
716
757
  await store.delete(getHashStoreKey(hash))
717
758
  }
718
759
 
760
+ /** @internal */
761
+ async function markSponsoredSenderInFlight(
762
+ store: Store.AtomicStore<charge.StoreItemMap>,
763
+ parameters: { chainId: number; sender: `0x${string}` },
764
+ ): Promise<boolean> {
765
+ return store.update(getSponsoredSenderStoreKey(parameters), (current) => {
766
+ if (current !== null) return { op: 'noop', result: false }
767
+ return { op: 'set', value: Date.now(), result: true }
768
+ })
769
+ }
770
+
771
+ /** @internal */
772
+ async function releaseSponsoredSenderInFlight(
773
+ store: Store.AtomicStore<charge.StoreItemMap>,
774
+ parameters: { chainId: number; sender: `0x${string}` },
775
+ ): Promise<void> {
776
+ await store.delete(getSponsoredSenderStoreKey(parameters))
777
+ }
778
+
719
779
  /** @internal */
720
780
  async function markProofUsed(
721
781
  store: Store.AtomicStore<charge.StoreItemMap>,