mppx 0.8.9 → 0.8.11

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 (98) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/cli/validate/challenge.d.ts +2 -0
  3. package/dist/cli/validate/challenge.d.ts.map +1 -1
  4. package/dist/cli/validate/challenge.js +21 -9
  5. package/dist/cli/validate/challenge.js.map +1 -1
  6. package/dist/cli/validate/discovery.d.ts.map +1 -1
  7. package/dist/cli/validate/discovery.js +50 -19
  8. package/dist/cli/validate/discovery.js.map +1 -1
  9. package/dist/cli/validate/helpers.d.ts +1 -0
  10. package/dist/cli/validate/helpers.d.ts.map +1 -1
  11. package/dist/cli/validate/helpers.js +11 -0
  12. package/dist/cli/validate/helpers.js.map +1 -1
  13. package/dist/cli/validate/index.d.ts +1 -0
  14. package/dist/cli/validate/index.d.ts.map +1 -1
  15. package/dist/cli/validate/index.js +4 -0
  16. package/dist/cli/validate/index.js.map +1 -1
  17. package/dist/cli/validate/payment.d.ts +1 -0
  18. package/dist/cli/validate/payment.d.ts.map +1 -1
  19. package/dist/cli/validate/payment.js +2 -2
  20. package/dist/cli/validate/payment.js.map +1 -1
  21. package/dist/tempo/internal/fee-payer.d.ts +39 -2
  22. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  23. package/dist/tempo/internal/fee-payer.js +45 -1
  24. package/dist/tempo/internal/fee-payer.js.map +1 -1
  25. package/dist/tempo/internal/fee-token.d.ts +8 -0
  26. package/dist/tempo/internal/fee-token.d.ts.map +1 -1
  27. package/dist/tempo/internal/fee-token.js +19 -6
  28. package/dist/tempo/internal/fee-token.js.map +1 -1
  29. package/dist/tempo/legacy/session/Chain.d.ts.map +1 -1
  30. package/dist/tempo/legacy/session/Chain.js +53 -50
  31. package/dist/tempo/legacy/session/Chain.js.map +1 -1
  32. package/dist/tempo/server/Charge.d.ts +8 -0
  33. package/dist/tempo/server/Charge.d.ts.map +1 -1
  34. package/dist/tempo/server/Charge.js +49 -45
  35. package/dist/tempo/server/Charge.js.map +1 -1
  36. package/dist/tempo/server/Methods.d.ts +7 -0
  37. package/dist/tempo/server/Methods.d.ts.map +1 -1
  38. package/dist/tempo/server/Methods.js.map +1 -1
  39. package/dist/tempo/server/Subscription.d.ts.map +1 -1
  40. package/dist/tempo/server/Subscription.js +24 -16
  41. package/dist/tempo/server/Subscription.js.map +1 -1
  42. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  43. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  44. package/dist/tempo/server/internal/html.gen.js +1 -1
  45. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  46. package/dist/tempo/session/client/SessionManager.d.ts +3 -0
  47. package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
  48. package/dist/tempo/session/client/SessionManager.js +1 -0
  49. package/dist/tempo/session/client/SessionManager.js.map +1 -1
  50. package/dist/tempo/session/client/Transports.d.ts +20 -0
  51. package/dist/tempo/session/client/Transports.d.ts.map +1 -1
  52. package/dist/tempo/session/client/Transports.js +23 -11
  53. package/dist/tempo/session/client/Transports.js.map +1 -1
  54. package/dist/tempo/session/precompile/Chain.d.ts.map +1 -1
  55. package/dist/tempo/session/precompile/Chain.js +25 -19
  56. package/dist/tempo/session/precompile/Chain.js.map +1 -1
  57. package/dist/tempo/session/server/index.d.ts +2 -0
  58. package/dist/tempo/session/server/index.d.ts.map +1 -1
  59. package/dist/tempo/session/server/index.js +2 -0
  60. package/dist/tempo/session/server/index.js.map +1 -1
  61. package/dist/validation/core.d.ts +1 -0
  62. package/dist/validation/core.d.ts.map +1 -1
  63. package/dist/validation/core.js +5 -0
  64. package/dist/validation/core.js.map +1 -1
  65. package/dist/x402/Header.js +1 -1
  66. package/dist/x402/Header.js.map +1 -1
  67. package/package.json +1 -1
  68. package/src/cli/validate/challenge.ts +20 -8
  69. package/src/cli/validate/discovery.test.ts +128 -0
  70. package/src/cli/validate/discovery.ts +50 -15
  71. package/src/cli/validate/helpers.ts +10 -0
  72. package/src/cli/validate/index.ts +4 -0
  73. package/src/cli/validate/payment.ts +4 -1
  74. package/src/cli/validate.test.ts +23 -2
  75. package/src/client/Mppx.test-d.ts +3 -1
  76. package/src/tempo/PublicExports.test-d.ts +5 -0
  77. package/src/tempo/internal/fee-payer.test.ts +93 -0
  78. package/src/tempo/internal/fee-payer.ts +64 -2
  79. package/src/tempo/internal/fee-token.test.ts +37 -0
  80. package/src/tempo/internal/fee-token.ts +26 -5
  81. package/src/tempo/legacy/session/Chain.test.ts +78 -2
  82. package/src/tempo/legacy/session/Chain.ts +56 -52
  83. package/src/tempo/server/Charge.test.ts +121 -6
  84. package/src/tempo/server/Charge.ts +61 -48
  85. package/src/tempo/server/Methods.ts +6 -0
  86. package/src/tempo/server/Subscription.test.ts +79 -4
  87. package/src/tempo/server/Subscription.ts +24 -16
  88. package/src/tempo/server/internal/html.gen.ts +1 -1
  89. package/src/tempo/session/client/SessionManager.test.ts +12 -1
  90. package/src/tempo/session/client/SessionManager.ts +3 -0
  91. package/src/tempo/session/client/Transports.test.ts +34 -0
  92. package/src/tempo/session/client/Transports.ts +47 -20
  93. package/src/tempo/session/precompile/Chain.test.ts +9 -0
  94. package/src/tempo/session/precompile/Chain.ts +28 -20
  95. package/src/tempo/session/server/Session.test.ts +23 -3
  96. package/src/tempo/session/server/index.ts +2 -0
  97. package/src/validation/core.ts +6 -0
  98. package/src/x402/Header.ts +1 -1
@@ -1619,8 +1619,10 @@ describe('tempo', () => {
1619
1619
  httpServer.close()
1620
1620
  })
1621
1621
 
1622
- test('behavior: fee payer pre-broadcast simulation targets the co-signed transaction', async () => {
1623
- // The pre-broadcast simulation must reflect the FINAL co-signed envelope
1622
+ test('behavior: fee payer simulates the sender and final sponsored envelopes', async () => {
1623
+ // First simulate execution as the sender with fee fields omitted. This
1624
+ // catches call-level reverts without requiring the sender to fund fees.
1625
+ // The second simulation must reflect the FINAL co-signed envelope
1624
1626
  // (concrete sponsor fee payer), not the pre-cosign 0x78 (`feePayer: true`).
1625
1627
  const callRequests: any[] = []
1626
1628
  const interceptingClient = createClient({
@@ -1628,7 +1630,8 @@ describe('tempo', () => {
1628
1630
  chain: client.chain,
1629
1631
  transport: custom({
1630
1632
  async request(args: any) {
1631
- if (args.method === 'eth_call') callRequests.push(args.params?.[0])
1633
+ if (args.method === 'eth_call' && args.params?.[0]?.calls)
1634
+ callRequests.push(args.params[0])
1632
1635
  return client.transport.request(args)
1633
1636
  },
1634
1637
  }),
@@ -1682,14 +1685,16 @@ describe('tempo', () => {
1682
1685
  })
1683
1686
  expect(authResponse.status).toBe(200)
1684
1687
 
1685
- expect(callRequests.length).toBeGreaterThan(0)
1686
- const simRequest = callRequests[0]
1688
+ expect(callRequests).toHaveLength(2)
1689
+ expect(callRequests[0]).not.toHaveProperty('feePayer')
1690
+ const simRequest = callRequests[1]
1687
1691
  // The co-signed envelope names a concrete sponsor as fee payer. The
1688
1692
  // pre-cosign 0x78 instead carries `feePayer: true`; asserting the address
1689
1693
  // proves we simulate the transaction the sponsor actually broadcasts.
1690
1694
  expect(simRequest.feePayer).not.toBe(true)
1691
1695
  expect(typeof simRequest.feePayer).toBe('string')
1692
1696
  expect((simRequest.feePayer as string).toLowerCase()).toBe(accounts[0].address.toLowerCase())
1697
+ expect(simRequest.feeToken?.toLowerCase()).toBe(defaults.tokens.pathUsd.toLowerCase())
1693
1698
  // Execution runs as the sender, not the sponsor.
1694
1699
  expect((simRequest.from as string).toLowerCase()).toBe(accounts[1].address.toLowerCase())
1695
1700
  expect(simRequest.calls?.length).toBeGreaterThan(0)
@@ -1770,6 +1775,77 @@ describe('tempo', () => {
1770
1775
  httpServer.close()
1771
1776
  })
1772
1777
 
1778
+ test('behavior: fee payer does not broadcast when final simulation reverts', async () => {
1779
+ const rpcMethods: string[] = []
1780
+ let simulations = 0
1781
+ const interceptingClient = createClient({
1782
+ account: accounts[0],
1783
+ chain: client.chain,
1784
+ transport: custom({
1785
+ async request(args: any) {
1786
+ rpcMethods.push(args.method)
1787
+ if (args.method === 'eth_call' && ++simulations >= 2)
1788
+ throw new Error('execution reverted: final simulation fixture')
1789
+ return client.transport.request(args)
1790
+ },
1791
+ }),
1792
+ })
1793
+
1794
+ const serverWithRevert = Mppx_server.create({
1795
+ methods: [
1796
+ tempo_server.charge({
1797
+ getClient() {
1798
+ return interceptingClient
1799
+ },
1800
+ currency: asset,
1801
+ account: accounts[0],
1802
+ }),
1803
+ ],
1804
+ realm,
1805
+ secretKey,
1806
+ })
1807
+
1808
+ const mppx = Mppx_client.create({
1809
+ polyfill: false,
1810
+ methods: [
1811
+ tempo_client({
1812
+ account: accounts[1],
1813
+ getClient() {
1814
+ return client
1815
+ },
1816
+ }),
1817
+ ],
1818
+ })
1819
+
1820
+ const httpServer = await Http.createServer(async (req, res) => {
1821
+ const result = await Mppx_server.toNodeListener(
1822
+ serverWithRevert.charge({
1823
+ feePayer: accounts[0],
1824
+ amount: '1',
1825
+ currency: asset,
1826
+ recipient: accounts[0].address,
1827
+ }),
1828
+ )(req, res)
1829
+ if (result.status === 402) return
1830
+ res.end('OK')
1831
+ })
1832
+
1833
+ const challengeResponse = await fetch(httpServer.url)
1834
+ const credential = await mppx.createCredential(challengeResponse)
1835
+ rpcMethods.length = 0
1836
+
1837
+ const authResponse = await fetch(httpServer.url, {
1838
+ headers: { Authorization: credential },
1839
+ })
1840
+
1841
+ expect(authResponse.status).not.toBe(200)
1842
+ expect(simulations).toBeGreaterThanOrEqual(2)
1843
+ expect(rpcMethods).not.toContain('eth_sendRawTransactionSync')
1844
+ expect(rpcMethods).not.toContain('eth_sendRawTransaction')
1845
+
1846
+ httpServer.close()
1847
+ })
1848
+
1773
1849
  test('behavior: fee payer fails closed when simulation reverts (optimistic mode)', async () => {
1774
1850
  const rpcMethods: string[] = []
1775
1851
  const interceptingClient = createClient({
@@ -2054,11 +2130,30 @@ describe('tempo', () => {
2054
2130
 
2055
2131
  test('behavior: fee payer URL (withFeePayer transport)', async () => {
2056
2132
  const feePayerRequests: any[] = []
2133
+ const sequence: string[] = []
2134
+ let rejectFinalSimulation = false
2135
+ let simulations = 0
2136
+ const interceptingClient = createClient({
2137
+ account: accounts[0],
2138
+ chain: client.chain,
2139
+ transport: custom({
2140
+ async request(args: any) {
2141
+ if (args.method === 'eth_call' && args.params?.[0]?.calls) {
2142
+ sequence.push('simulate')
2143
+ if (rejectFinalSimulation && ++simulations >= 2)
2144
+ throw new Error('hosted final simulation reverted')
2145
+ }
2146
+ if (args.method === 'eth_sendRawTransactionSync') sequence.push('broadcast')
2147
+ return client.transport.request(args)
2148
+ },
2149
+ }),
2150
+ })
2057
2151
  const feePayerServer = await Http.createServer(async (req, res) => {
2058
2152
  let requestBody = ''
2059
2153
  for await (const chunk of req) requestBody += chunk
2060
2154
  const request = JSON.parse(requestBody)
2061
2155
  feePayerRequests.push(request)
2156
+ sequence.push('complete')
2062
2157
 
2063
2158
  const transaction = request.params[0]
2064
2159
  const quantity = (value: unknown) =>
@@ -2116,7 +2211,7 @@ describe('tempo', () => {
2116
2211
  methods: [
2117
2212
  tempo_server.charge({
2118
2213
  feePayer: feePayerServer.url,
2119
- getClient: () => client,
2214
+ getClient: () => interceptingClient,
2120
2215
  currency: asset,
2121
2216
  }),
2122
2217
  ],
@@ -2149,6 +2244,7 @@ describe('tempo', () => {
2149
2244
  const response = await mppx.fetch(httpServer.url)
2150
2245
  expect(response.status).toBe(200)
2151
2246
  expect(feePayerRequests.map(({ method }) => method)).toEqual(['eth_fillTransaction'])
2247
+ expect(sequence).toEqual(['simulate', 'complete', 'simulate', 'broadcast'])
2152
2248
 
2153
2249
  const receipt = Receipt.fromResponse(response)
2154
2250
  expect(receipt.status).toBe('success')
@@ -2158,10 +2254,29 @@ describe('tempo', () => {
2158
2254
  })
2159
2255
  expect((txReceipt as any).feePayer).toBe(accounts[0].address.toLowerCase())
2160
2256
 
2257
+ sequence.length = 0
2258
+ simulations = 0
2259
+ rejectFinalSimulation = true
2260
+ const rejected = await mppx.fetch(httpServer.url)
2261
+
2262
+ expect(rejected.status).not.toBe(200)
2263
+ expect(feePayerRequests).toHaveLength(2)
2264
+ expect(sequence.slice(0, 2)).toEqual(['simulate', 'complete'])
2265
+ expect(sequence).not.toContain('broadcast')
2266
+
2161
2267
  httpServer.close()
2162
2268
  feePayerServer.close()
2163
2269
  })
2164
2270
 
2271
+ test('error: rejects a fee token configured for a remote fee payer', () => {
2272
+ expect(() =>
2273
+ tempo_server.charge({
2274
+ feePayer: 'https://fee-payer.example',
2275
+ feeToken: defaults.tokens.pathUsd,
2276
+ }),
2277
+ ).toThrow('`feeToken` can only be configured for a local fee payer.')
2278
+ })
2279
+
2165
2280
  test('behavior: access keys', async () => {
2166
2281
  const rootAccount = accounts[1]
2167
2282
  const accessKey = Account.fromSecp256k1(Secp256k1.randomPrivateKey(), {
@@ -31,6 +31,7 @@ import * as TempoAddress from '../internal/address.js'
31
31
  import * as Charge_internal from '../internal/charge.js'
32
32
  import * as defaults from '../internal/defaults.js'
33
33
  import * as FeePayer from '../internal/fee-payer.js'
34
+ import { resolveFeeToken } from '../internal/fee-token.js'
34
35
  import * as Proof from '../internal/proof.js'
35
36
  import * as Selectors from '../internal/selectors.js'
36
37
  import type * as types from '../internal/types.js'
@@ -59,6 +60,7 @@ export function charge<const parameters extends charge.Parameters>(
59
60
  decimals = defaults.decimals,
60
61
  description,
61
62
  externalId,
63
+ feeToken: configuredFeeToken,
62
64
  feePayerPolicy,
63
65
  html,
64
66
  memo,
@@ -79,6 +81,8 @@ export function charge<const parameters extends charge.Parameters>(
79
81
  : undefined
80
82
 
81
83
  const { recipient, feePayer, feePayerUrl } = Account.resolve(parameters)
84
+ if (configuredFeeToken && feePayerUrl)
85
+ throw new Error('`feeToken` can only be configured for a local fee payer.')
82
86
 
83
87
  const getClient = Client.getResolver({
84
88
  chain: { ...tempo_chain, experimental_preconfirmationTime: 500 },
@@ -405,67 +409,68 @@ export function charge<const parameters extends charge.Parameters>(
405
409
 
406
410
  const allowedFeeTokens = FeePayer.defaultAllowedFeeTokens(chainId)
407
411
  if (isFeePayerTx) FeePayer.assertAllowedFeeToken(transaction, allowedFeeTokens)
408
-
409
- // Request for the pre-broadcast simulation; for sponsored payments
410
- // this is overwritten below with the co-signed shape.
411
- let simulationRequest: Record<string, unknown> = FeePayer.simulationTransaction(
412
- transaction,
413
- { feePayer: isFeePayerTx },
414
- )
412
+ const selectableFeeTokens = allowedFeeTokens as readonly `0x${string}`[]
415
413
 
416
414
  const serializedTransaction_final = await (async () => {
417
415
  if (feePayerAccount && methodDetails?.feePayer !== false) {
418
- const sponsored = FeePayer.prepareSponsoredTransaction({
419
- account: feePayerAccount,
420
- allowedFeeTokens,
421
- challengeExpires: expires,
422
- chainId: chainId ?? client.chain!.id,
423
- details: { amount, currency, recipient },
424
- policy: feePayerPolicy,
425
- transaction: {
426
- ...transaction,
427
- feeToken: transaction.feeToken ?? defaults.tokens.pathUsd,
416
+ const completed = await FeePayer.preflightSponsorship({
417
+ transaction,
418
+ simulate: (request) => viem_call(client, request as never),
419
+ async complete() {
420
+ const feeToken =
421
+ configuredFeeToken ??
422
+ (await resolveFeeToken({
423
+ account: feePayerAccount.address,
424
+ allowedTokens: selectableFeeTokens,
425
+ candidateTokens: selectableFeeTokens,
426
+ client,
427
+ prioritizeCandidates: true,
428
+ }))
429
+ const sponsored = FeePayer.prepareSponsoredTransaction({
430
+ account: feePayerAccount,
431
+ allowedFeeTokens,
432
+ challengeExpires: expires,
433
+ chainId: chainId ?? client.chain!.id,
434
+ details: { amount, currency, recipient },
435
+ policy: feePayerPolicy,
436
+ transaction: {
437
+ ...transaction,
438
+ ...(feeToken ? { feeToken } : {}),
439
+ },
440
+ })
441
+ return { feePayer: feePayerAccount.address, transaction: sponsored }
428
442
  },
429
443
  })
430
- // `account` is the sender (eth_call `from`); `feePayer` is the
431
- // sponsor that pays gas.
432
- simulationRequest = {
433
- ...sponsored,
434
- account: transaction.from,
435
- feePayer: feePayerAccount.address,
436
- feePayerSignature: undefined,
437
- signature: undefined,
438
- }
439
- return signTransaction(client, sponsored as never)
444
+ return signTransaction(client, completed.transaction as never)
440
445
  }
441
446
  if (feePayerUrl && isFeePayerTx) {
442
- const hosted = await FeePayer.fillHostedFeePayerTransaction({
443
- allowedFeeTokens,
444
- challengeExpires: expires,
445
- chainId: chainId ?? client.chain!.id,
446
- details: { amount, currency, recipient },
447
- policy: feePayerPolicy,
447
+ const completed = await FeePayer.preflightSponsorship({
448
448
  transaction,
449
- url: feePayerUrl,
449
+ simulate: (request) => viem_call(client, request as never),
450
+ async complete() {
451
+ const hosted = await FeePayer.fillHostedFeePayerTransaction({
452
+ allowedFeeTokens,
453
+ challengeExpires: expires,
454
+ chainId: chainId ?? client.chain!.id,
455
+ details: { amount, currency, recipient },
456
+ policy: feePayerPolicy,
457
+ transaction,
458
+ url: feePayerUrl,
459
+ })
460
+ return { ...hosted, transaction }
461
+ },
450
462
  })
451
- // Simulate the co-signed envelope (concrete fee payer + chosen
452
- // fee token), mirroring the local-sponsor path.
453
- simulationRequest = {
454
- ...transaction,
455
- account: transaction.from,
456
- feePayer: hosted.feePayer,
457
- feePayerSignature: undefined,
458
- feeToken: hosted.feeToken,
459
- signature: undefined,
460
- }
461
- return hosted.serializedTransaction
463
+ return completed.serializedTransaction
462
464
  }
463
465
  return serializedTransaction
464
466
  })()
465
467
 
466
- // Pre-broadcast simulation: fail closed before broadcast so the
467
- // sponsor never pays gas for a transaction that would revert.
468
- await viem_call(client, simulationRequest as never)
468
+ // Pre-broadcast simulation for non-sponsored transactions.
469
+ if (!isFeePayerTx)
470
+ await viem_call(
471
+ client,
472
+ FeePayer.simulationTransaction(transaction, { feePayer: false }) as never,
473
+ )
469
474
 
470
475
  if (waitForConfirmation) {
471
476
  const receipt = await sendRawTransactionSync(client, {
@@ -563,6 +568,14 @@ export declare namespace charge {
563
568
  * `maxTotalFee` so the combined fee budget remains valid.
564
569
  */
565
570
  feePayerPolicy?: FeePayerPolicy | undefined
571
+ /**
572
+ * Token a local fee payer uses to pay gas. If omitted, mppx selects a
573
+ * funded allowed token, preferring pathUSD.
574
+ *
575
+ * This option is not supported with a remote fee-payer URL, which selects
576
+ * its own token.
577
+ */
578
+ feeToken?: `0x${string}` | undefined
566
579
  /** Testnet mode. */
567
580
  testnet?: boolean | undefined
568
581
  /**
@@ -5,6 +5,7 @@ import {
5
5
  settle as settle_,
6
6
  settleBatch as settleBatch_,
7
7
  } from '../session/server/Session.js'
8
+ import type { SessionController as SessionController_ } from '../session/server/Sse.js'
8
9
  import * as Ws_ from '../session/server/Ws.js'
9
10
  import { charge as charge_ } from './Charge.js'
10
11
  import { renew as renewSubscription_, subscription as subscription_ } from './Subscription.js'
@@ -83,6 +84,11 @@ export namespace tempo {
83
84
  export const settle = settle_
84
85
  /** Batch-settle precompile-backed session channels. */
85
86
  export const settleBatch = settleBatch_
87
+ /** Types for Tempo session streams. */
88
+ export namespace Sse {
89
+ /** Controller passed to manual-charge SSE generators. */
90
+ export type SessionController = SessionController_
91
+ }
86
92
  /** Experimental websocket helpers for Tempo sessions. */
87
93
  export const Ws = Ws_
88
94
  }
@@ -112,22 +112,76 @@ async function createCredential(
112
112
  })
113
113
  }
114
114
 
115
- function createBillingClient(hashes: readonly string[]) {
115
+ function createBillingClient(
116
+ hashes: readonly string[],
117
+ options: { account?: typeof rootAccount; failSimulation?: number | undefined } = {},
118
+ ) {
119
+ const callRequests: Record<string, unknown>[] = []
116
120
  const rpcMethods: string[] = []
117
121
  let nextHash = 0
122
+ let simulations = 0
118
123
  const client = createClient({
124
+ account: options.account,
119
125
  chain: { ...tempo_chain, id: chainId },
120
126
  transport: custom({
121
- async request({ method }) {
127
+ async request({ method, params }) {
122
128
  rpcMethods.push(method)
123
129
  if (method === 'eth_chainId') return `0x${chainId.toString(16)}`
124
- if (method === 'eth_call') return '0x'
130
+ if (method === 'eth_getTransactionCount') return '0x0'
131
+ if (method === 'eth_estimateGas') return '0x5208'
132
+ if (method === 'eth_maxPriorityFeePerGas') return '0x1'
133
+ if (method === 'eth_getBlockByNumber') return { baseFeePerGas: '0x1' }
134
+ if (method === 'eth_call') {
135
+ callRequests.push((params as [Record<string, unknown>])[0])
136
+ if (options.failSimulation !== undefined && ++simulations >= options.failSimulation)
137
+ throw new Error('final sponsored simulation reverted')
138
+ return '0x'
139
+ }
125
140
  if (method === 'eth_sendRawTransaction') return hashes[nextHash++] ?? hashActivate
126
141
  throw new Error(`unexpected rpc method: ${method}`)
127
142
  },
128
143
  }),
129
144
  })
130
- return { client, rpcMethods }
145
+ return { callRequests, client, rpcMethods }
146
+ }
147
+
148
+ async function activateFeeSponsoredSubscription(options?: { failSimulation?: number | undefined }) {
149
+ const store = Store.memory()
150
+ const billing = createBillingClient([hashActivate], {
151
+ account: rootAccount,
152
+ ...options,
153
+ })
154
+ const method = subscription({
155
+ amount: subscriptionAmount,
156
+ chainId,
157
+ currency: subscriptionCurrency,
158
+ feePayer: rootAccount,
159
+ getClient: async () => billing.client,
160
+ periodCount: subscriptionPeriodCount,
161
+ periodUnit: subscriptionPeriodUnit,
162
+ recipient: subscriptionRecipient,
163
+ resolve: async () => ({ key: subscriptionKey }),
164
+ store,
165
+ subscriptionExpires: activeSubscriptionExpires,
166
+ waitForConfirmation: false,
167
+ })
168
+ const mppx = Mppx.create({ methods: [method], realm, secretKey })
169
+ const challengeResult = await mppx.tempo.subscription({})(
170
+ new Request('https://example.com/resource'),
171
+ )
172
+ if (challengeResult.status !== 402) throw new Error('expected activation challenge')
173
+ const challenge = Challenge.fromResponse(challengeResult.challenge)
174
+ const accessKey = (
175
+ challenge.request as ReturnType<typeof Methods.subscription.schema.request.parse>
176
+ ).methodDetails?.accessKey
177
+ if (!accessKey) throw new Error('expected generated access key')
178
+ const credential = await createCredential(challenge, rootAccount.address, accessKey)
179
+ const result = await mppx.tempo.subscription({})(
180
+ new Request('https://example.com/resource', {
181
+ headers: { Authorization: Credential.serialize(credential) },
182
+ }),
183
+ )
184
+ return { ...billing, result }
131
185
  }
132
186
 
133
187
  const confirmedBlockHash = `0x${'f'.repeat(64)}` as const
@@ -234,6 +288,27 @@ function createConfirmingBillingClient(options?: {
234
288
  }
235
289
 
236
290
  describe('tempo.subscription', () => {
291
+ test('preflights sender and final envelopes for fee-sponsored activation', async () => {
292
+ const { callRequests, result, rpcMethods } = await activateFeeSponsoredSubscription()
293
+
294
+ expect(result.status).toBe(200)
295
+ expect(callRequests).toHaveLength(2)
296
+ expect(callRequests[0]).not.toHaveProperty('feePayer')
297
+ expect(callRequests[1]?.feePayer).toBe(rootAccount.address)
298
+ expect(rpcMethods).toContain('eth_sendRawTransaction')
299
+ })
300
+
301
+ test('does not broadcast a fee-sponsored activation after final simulation failure', async () => {
302
+ const { callRequests, result, rpcMethods } = await activateFeeSponsoredSubscription({
303
+ failSimulation: 2,
304
+ })
305
+
306
+ expect(result.status).not.toBe(200)
307
+ expect(callRequests.length).toBeGreaterThanOrEqual(2)
308
+ expect(rpcMethods).not.toContain('eth_sendRawTransaction')
309
+ expect(rpcMethods).not.toContain('eth_sendRawTransactionSync')
310
+ })
311
+
237
312
  test('stores an activated subscription and reuses it on later requests', async () => {
238
313
  const store = Store.memory()
239
314
  let activationCount = 0
@@ -965,28 +965,36 @@ async function submitSubscriptionPayment(parameters: {
965
965
  const userTransaction = Transaction.deserialize(
966
966
  userSerialized as Transaction.TransactionSerializedTempo,
967
967
  )
968
- const sponsored = FeePayer.prepareSponsoredTransaction({
969
- account: feePayer,
970
- chainId: chainId ?? client.chain!.id,
971
- details: {
972
- amount: String(request.amount),
973
- currency: String(request.currency),
974
- recipient: String(request.recipient),
968
+ const completed = await FeePayer.preflightSponsorship({
969
+ transaction: userTransaction,
970
+ simulate: (request) => viem_call(client, request as never),
971
+ async complete() {
972
+ const sponsored = FeePayer.prepareSponsoredTransaction({
973
+ account: feePayer,
974
+ chainId: chainId ?? client.chain!.id,
975
+ details: {
976
+ amount: String(request.amount),
977
+ currency: String(request.currency),
978
+ recipient: String(request.recipient),
979
+ },
980
+ ...(feePayerPolicy ? { policy: feePayerPolicy } : {}),
981
+ transaction: userTransaction as never,
982
+ })
983
+ return { feePayer: feePayer.address, transaction: sponsored }
975
984
  },
976
- ...(feePayerPolicy ? { policy: feePayerPolicy } : {}),
977
- transaction: userTransaction as never,
978
985
  })
979
- return await signTransaction(client, sponsored as never)
986
+ return await signTransaction(client, completed.transaction as never)
980
987
  })()
981
988
  const transaction = Transaction.deserialize(
982
989
  serializedTransaction as Transaction.TransactionSerializedTempo,
983
990
  )
984
- await viem_call(client, {
985
- ...transaction,
986
- account: transaction.from,
987
- calls: transaction.calls,
988
- feePayerSignature: undefined,
989
- } as never)
991
+ if (!feePayer)
992
+ await viem_call(client, {
993
+ ...transaction,
994
+ account: transaction.from,
995
+ calls: transaction.calls,
996
+ feePayerSignature: undefined,
997
+ } as never)
990
998
 
991
999
  if (!waitForConfirmation) {
992
1000
  // Optimistic mode has no receipt to inspect, so it cannot detect a T6