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
@@ -11,8 +11,10 @@ import {
11
11
  defaultAllowedFeeTokens,
12
12
  FeePayerValidationError,
13
13
  fillHostedFeePayerTransaction,
14
+ preflightSponsorship,
14
15
  prepareSponsoredTransaction,
15
16
  simulationTransaction,
17
+ sponsoredSimulationTransaction,
16
18
  validateCalls,
17
19
  } from './fee-payer.js'
18
20
  import * as Selectors from './selectors.js'
@@ -768,6 +770,97 @@ describe('simulationTransaction', () => {
768
770
  feePayerSignature: undefined,
769
771
  })
770
772
  })
773
+
774
+ test('builds the final sponsored envelope without either signature', () => {
775
+ const transaction = {
776
+ calls: [{ to: bogus }],
777
+ feePayerSignature,
778
+ feeToken: swapTokenIn,
779
+ from: bogus,
780
+ signature: { r: 1n, s: 2n, yParity: 0 },
781
+ }
782
+
783
+ expect(
784
+ sponsoredSimulationTransaction(transaction as any, {
785
+ feePayer: swapTokenOut,
786
+ feeToken: bogus,
787
+ }),
788
+ ).toEqual({
789
+ ...transaction,
790
+ account: bogus,
791
+ calls: transaction.calls,
792
+ feePayer: swapTokenOut,
793
+ feePayerSignature: undefined,
794
+ feeToken: bogus,
795
+ signature: undefined,
796
+ })
797
+ })
798
+ })
799
+
800
+ describe('preflightSponsorship', () => {
801
+ const transaction = {
802
+ calls: [{ to: bogus }],
803
+ feeToken: swapTokenIn,
804
+ from: bogus,
805
+ }
806
+
807
+ test('simulates the sender before completion and the completed envelope after', async () => {
808
+ const simulations: Record<string, unknown>[] = []
809
+ let completions = 0
810
+
811
+ const result = await preflightSponsorship({
812
+ transaction: transaction as any,
813
+ async simulate(request) {
814
+ simulations.push(request)
815
+ },
816
+ async complete() {
817
+ completions += 1
818
+ return { feePayer: swapTokenOut, transaction: transaction as any }
819
+ },
820
+ })
821
+
822
+ expect(completions).toBe(1)
823
+ expect(result.feePayer).toBe(swapTokenOut)
824
+ expect(simulations).toEqual([
825
+ { account: bogus, calls: transaction.calls },
826
+ {
827
+ ...transaction,
828
+ account: bogus,
829
+ calls: transaction.calls,
830
+ feePayer: swapTokenOut,
831
+ feePayerSignature: undefined,
832
+ signature: undefined,
833
+ },
834
+ ])
835
+ })
836
+
837
+ test.each([
838
+ { completeCalls: 0, failingSimulation: 1, simulationCalls: 1 },
839
+ { completeCalls: 1, failingSimulation: 2, simulationCalls: 2 },
840
+ ])(
841
+ 'stops at simulation $failingSimulation',
842
+ async ({ completeCalls, failingSimulation, simulationCalls }) => {
843
+ let completions = 0
844
+ let simulations = 0
845
+
846
+ await expect(
847
+ preflightSponsorship({
848
+ transaction: transaction as any,
849
+ async simulate() {
850
+ simulations += 1
851
+ if (simulations === failingSimulation) throw new Error('simulation failed')
852
+ },
853
+ async complete() {
854
+ completions += 1
855
+ return { feePayer: swapTokenOut, transaction: transaction as any }
856
+ },
857
+ }),
858
+ ).rejects.toThrow('simulation failed')
859
+
860
+ expect(completions).toBe(completeCalls)
861
+ expect(simulations).toBe(simulationCalls)
862
+ },
863
+ )
771
864
  })
772
865
 
773
866
  describe('prepareSponsoredTransaction', () => {
@@ -228,7 +228,7 @@ export async function fillHostedFeePayerTransaction(parameters: {
228
228
  ...(filled.feePayerSignature as Record<string, unknown>),
229
229
  yParity: Number((filled.feePayerSignature as { yParity?: unknown }).yParity),
230
230
  }
231
- const feeToken = filled.feeToken
231
+ const feeToken = filled.feeToken as TempoAddress.Address
232
232
 
233
233
  // Recover the concrete sponsor address so the simulation can use a concrete
234
234
  // `feePayer` (the node rejects `eth_call` with `feePayer: true`).
@@ -266,7 +266,14 @@ export async function fillHostedFeePayerTransaction(parameters: {
266
266
  }
267
267
  }
268
268
 
269
- /** Returns a transaction shape suitable for pre-broadcast simulation. */
269
+ /**
270
+ * Returns a transaction shape suitable for pre-broadcast simulation.
271
+ *
272
+ * Sponsored transactions are first simulated as calls from the sender with no
273
+ * fee fields or signatures. This checks call execution without requiring the
274
+ * sender to hold the fee token; transferred value and call-level balances are
275
+ * still checked by the RPC.
276
+ */
270
277
  export function simulationTransaction(
271
278
  transaction: SponsoredTransaction,
272
279
  options: { feePayer: boolean },
@@ -284,6 +291,61 @@ export function simulationTransaction(
284
291
  }
285
292
  }
286
293
 
294
+ /**
295
+ * Returns the final fee-sponsored transaction shape for pre-broadcast
296
+ * simulation. RPC `eth_call` does not carry either transaction signature.
297
+ */
298
+ export function sponsoredSimulationTransaction(
299
+ transaction: SponsoredTransaction,
300
+ options: { feePayer: TempoAddress.Address; feeToken?: TempoAddress.Address | undefined },
301
+ ) {
302
+ const { feePayer, feeToken } = options
303
+ return {
304
+ ...transaction,
305
+ account: transaction.from,
306
+ calls: transaction.calls,
307
+ feePayer,
308
+ feePayerSignature: undefined,
309
+ ...(feeToken !== undefined ? { feeToken } : {}),
310
+ signature: undefined,
311
+ }
312
+ }
313
+
314
+ /** A completed sponsorship envelope that can be simulated before signing or broadcast. */
315
+ export type PreflightSponsorship = {
316
+ feePayer: TempoAddress.Address
317
+ feeToken?: TempoAddress.Address | undefined
318
+ transaction: SponsoredTransaction
319
+ }
320
+
321
+ /**
322
+ * Runs execution-only sender and final-envelope simulations around sponsorship.
323
+ *
324
+ * First, it simulates the calls with the sender as `from`, omitting fee fields
325
+ * and signatures so the sender's fee balance is irrelevant. Next, `complete`
326
+ * resolves the sponsor and produces the co-signed transaction. Finally, it
327
+ * simulates that transaction with its concrete fee payer and fee token.
328
+ *
329
+ * `complete` runs only after sender-context execution succeeds, so a reverting
330
+ * transaction never reaches a local signer or hosted fee-payer.
331
+ */
332
+ export async function preflightSponsorship<sponsorship extends PreflightSponsorship>(parameters: {
333
+ complete: () => Promise<sponsorship>
334
+ simulate: (request: Record<string, unknown>) => Promise<unknown>
335
+ transaction: SponsoredTransaction
336
+ }): Promise<sponsorship> {
337
+ const { complete, simulate, transaction } = parameters
338
+ await simulate(simulationTransaction(transaction, { feePayer: true }))
339
+ const completed = await complete()
340
+ await simulate(
341
+ sponsoredSimulationTransaction(completed.transaction, {
342
+ feePayer: completed.feePayer,
343
+ feeToken: completed.feeToken,
344
+ }),
345
+ )
346
+ return completed
347
+ }
348
+
287
349
  /**
288
350
  * maxTotalFee must be high enough to cover `transferWithMemo` and
289
351
  * swap transactions at peak gas prices. Bumped from 0.01 ETH in #327.
@@ -77,6 +77,43 @@ describe.runIf(isLocalnet)('resolveFeeToken', () => {
77
77
  expectAddress(feeToken, asset)
78
78
  })
79
79
 
80
+ test('prioritizes funded allowed candidates', async () => {
81
+ const account = testAccount()
82
+ const client = clientFor(account)
83
+ await fundAccount({ address: account.address, token: asset })
84
+ await fundAccount({ address: account.address, token: Addresses.pathUsd })
85
+ await Actions.fee.setUserTokenSync(client, {
86
+ feeToken: asset,
87
+ token: asset,
88
+ } as never)
89
+
90
+ const feeToken = await resolveFeeToken({
91
+ account: account.address,
92
+ allowedTokens: [Addresses.pathUsd, asset],
93
+ candidateTokens: [Addresses.pathUsd, asset],
94
+ client,
95
+ prioritizeCandidates: true,
96
+ })
97
+
98
+ expectAddress(feeToken, Addresses.pathUsd)
99
+ })
100
+
101
+ test('falls through prioritized candidates when pathUSD is unfunded', async () => {
102
+ const account = testAccount()
103
+ const client = clientFor(account)
104
+ await fundAccount({ address: account.address, token: asset })
105
+
106
+ const feeToken = await resolveFeeToken({
107
+ account: account.address,
108
+ allowedTokens: [Addresses.pathUsd, asset],
109
+ candidateTokens: [Addresses.pathUsd, asset],
110
+ client,
111
+ prioritizeCandidates: true,
112
+ })
113
+
114
+ expectAddress(feeToken, asset)
115
+ })
116
+
80
117
  test('uses a funded chain fee token when configured', async () => {
81
118
  const account = testAccount()
82
119
  const client = createClient({
@@ -4,8 +4,17 @@ import { Actions, TokenId } from 'viem/tempo'
4
4
  import * as TempoAddress from './address.js'
5
5
  import * as defaults from './defaults.js'
6
6
 
7
- function pushUnique(tokens: Address[], token: Address | undefined) {
7
+ function pushUnique(
8
+ tokens: Address[],
9
+ token: Address | undefined,
10
+ allowedTokens?: readonly Address[] | undefined,
11
+ ) {
8
12
  if (!token) return
13
+ if (
14
+ allowedTokens &&
15
+ !allowedTokens.some((allowedToken) => TempoAddress.isEqual(allowedToken, token))
16
+ )
17
+ return
9
18
  if (tokens.some((t) => TempoAddress.isEqual(t, token))) return
10
19
  tokens.push(token)
11
20
  }
@@ -27,21 +36,33 @@ function getChainFeeToken(client: Client): Address | undefined {
27
36
  return chainId ? defaults.currency[chainId as keyof typeof defaults.currency] : undefined
28
37
  }
29
38
 
39
+ /**
40
+ * Resolves a funded fee token from account, chain, and caller-supplied preferences.
41
+ *
42
+ * `prioritizeCandidates` checks candidate tokens before account and chain
43
+ * preferences. `allowedTokens` limits every preference to the caller's policy.
44
+ */
30
45
  export async function resolveFeeToken(parameters: {
31
46
  account: Address
47
+ allowedTokens?: readonly Address[] | undefined
32
48
  candidateTokens?: readonly Address[] | undefined
33
49
  client: Client
50
+ prioritizeCandidates?: boolean | undefined
34
51
  }): Promise<Address | undefined> {
35
- const { account, candidateTokens, client } = parameters
52
+ const { account, allowedTokens, candidateTokens, client, prioritizeCandidates } = parameters
36
53
  const tokens: Address[] = []
37
54
 
55
+ if (prioritizeCandidates)
56
+ for (const token of candidateTokens ?? []) pushUnique(tokens, token, allowedTokens)
57
+
38
58
  const userToken = await Actions.fee
39
59
  .getUserToken(client as never, { account })
40
60
  .then((token) => token?.address as Address | undefined)
41
61
  .catch(() => undefined)
42
- pushUnique(tokens, userToken)
43
- pushUnique(tokens, getChainFeeToken(client))
44
- for (const token of candidateTokens ?? []) pushUnique(tokens, token)
62
+ pushUnique(tokens, userToken, allowedTokens)
63
+ pushUnique(tokens, getChainFeeToken(client), allowedTokens)
64
+ if (!prioritizeCandidates)
65
+ for (const token of candidateTokens ?? []) pushUnique(tokens, token, allowedTokens)
45
66
 
46
67
  for (const token of tokens) {
47
68
  if (await hasBalance(client, account, token)) return token
@@ -457,7 +457,7 @@ describe.runIf(isLocalnet)('on-chain', () => {
457
457
  ).rejects.toThrow('gas exceeds sponsor policy')
458
458
  })
459
459
 
460
- test('fee-payer: simulates open before broadcasting', async () => {
460
+ test('fee-payer: simulates open before broadcasting and recovers duplicate opens', async () => {
461
461
  const rpcMethods: string[] = []
462
462
  const interceptingClient = createClient({
463
463
  account: accounts[0],
@@ -521,6 +521,22 @@ describe.runIf(isLocalnet)('on-chain', () => {
521
521
  expect(broadcastIndex).toBeGreaterThan(-1)
522
522
  expect(simulationIndex).toBeGreaterThan(-1)
523
523
  expect(simulationIndex).toBeLessThan(broadcastIndex)
524
+ expect(
525
+ rpcMethods.slice(0, broadcastIndex).filter((method) => method === 'eth_call'),
526
+ ).toHaveLength(2)
527
+
528
+ const recovered = await broadcastOpenTransaction({
529
+ client: interceptingClient,
530
+ serializedTransaction,
531
+ escrowContract,
532
+ channelId,
533
+ recipient,
534
+ currency,
535
+ feePayer: accounts[0],
536
+ })
537
+
538
+ expect(recovered.txHash).toBeUndefined()
539
+ expect(recovered.onChain.deposit).toBe(deposit)
524
540
  })
525
541
 
526
542
  test('fee-payer: rejects smuggled second open call', async () => {
@@ -620,6 +636,17 @@ describe.runIf(isLocalnet)('on-chain', () => {
620
636
  })
621
637
 
622
638
  test('waitForConfirmation: false returns derived on-chain state', async () => {
639
+ const rpcMethods: string[] = []
640
+ const interceptingClient = createClient({
641
+ account: accounts[0],
642
+ chain: client.chain,
643
+ transport: custom({
644
+ async request(args: any) {
645
+ rpcMethods.push(args.method)
646
+ return client.transport.request(args)
647
+ },
648
+ }),
649
+ })
623
650
  const salt = nextSalt()
624
651
  const deposit = 10_000_000n
625
652
 
@@ -633,7 +660,7 @@ describe.runIf(isLocalnet)('on-chain', () => {
633
660
  })
634
661
 
635
662
  const result = await broadcastOpenTransaction({
636
- client,
663
+ client: interceptingClient,
637
664
  serializedTransaction,
638
665
  escrowContract,
639
666
  channelId,
@@ -649,6 +676,52 @@ describe.runIf(isLocalnet)('on-chain', () => {
649
676
  expect(result.onChain.deposit).toBe(deposit)
650
677
  expect(result.onChain.settled).toBe(0n)
651
678
  expect(result.onChain.finalized).toBe(false)
679
+ const simulationIndex = rpcMethods.indexOf('eth_call')
680
+ const broadcastIndex = rpcMethods.indexOf('eth_sendRawTransaction')
681
+ expect(simulationIndex).toBeGreaterThan(-1)
682
+ expect(broadcastIndex).toBeGreaterThan(-1)
683
+ expect(simulationIndex).toBeLessThan(broadcastIndex)
684
+ })
685
+
686
+ test('fee-payer relay: optimistic open simulates before broadcasting', async () => {
687
+ const rpcMethods: string[] = []
688
+ const interceptingClient = createClient({
689
+ account: accounts[0],
690
+ chain: client.chain,
691
+ transport: custom({
692
+ async request(args: any) {
693
+ rpcMethods.push(args.method)
694
+ return client.transport.request(args)
695
+ },
696
+ }),
697
+ })
698
+ const salt = nextSalt()
699
+ const deposit = 10_000_000n
700
+ const { channelId, serializedTransaction } = await signOpenChannel({
701
+ escrow: escrowContract,
702
+ payer,
703
+ payee: recipient,
704
+ token: currency,
705
+ deposit,
706
+ salt,
707
+ })
708
+
709
+ await broadcastOpenTransaction({
710
+ client: interceptingClient,
711
+ serializedTransaction,
712
+ escrowContract,
713
+ channelId,
714
+ recipient,
715
+ currency,
716
+ isSponsored: true,
717
+ waitForConfirmation: false,
718
+ })
719
+
720
+ const simulationIndex = rpcMethods.indexOf('eth_call')
721
+ const broadcastIndex = rpcMethods.indexOf('eth_sendRawTransaction')
722
+ expect(simulationIndex).toBeGreaterThan(-1)
723
+ expect(broadcastIndex).toBeGreaterThan(-1)
724
+ expect(simulationIndex).toBeLessThan(broadcastIndex)
652
725
  })
653
726
  })
654
727
 
@@ -1034,6 +1107,9 @@ describe.runIf(isLocalnet)('on-chain', () => {
1034
1107
  expect(broadcastIndex).toBeGreaterThan(-1)
1035
1108
  expect(simulationIndex).toBeGreaterThan(-1)
1036
1109
  expect(simulationIndex).toBeLessThan(broadcastIndex)
1110
+ expect(
1111
+ rpcMethods.slice(0, broadcastIndex).filter((method) => method === 'eth_call'),
1112
+ ).toHaveLength(2)
1037
1113
  })
1038
1114
 
1039
1115
  test('fee-payer: rejects smuggled second topUp call', async () => {
@@ -626,37 +626,47 @@ export async function broadcastOpenTransaction(
626
626
 
627
627
  await beforeBroadcast?.(pendingOnChain)
628
628
 
629
- const serializedTransaction_final = await (async () => {
629
+ const completeTransaction = async () => {
630
630
  if (feePayer) {
631
631
  if (!sponsoredOpenCall)
632
632
  throw new BadRequestError({
633
633
  reason: 'transaction does not contain a valid escrow open call',
634
634
  })
635
635
 
636
- const sponsored = FeePayer.prepareSponsoredTransaction({
637
- account: feePayer,
638
- allowedFeeTokens: defaultFeeToken ? [defaultFeeToken] : undefined,
639
- challengeExpires,
640
- chainId: client.chain!.id,
641
- details: { channelId, currency, recipient },
642
- policy: feePayerPolicy,
643
- transaction: {
644
- ...transaction,
645
- ...(resolvedFeeToken ? { feeToken: resolvedFeeToken } : {}),
636
+ const completed = await FeePayer.preflightSponsorship({
637
+ transaction,
638
+ simulate: (request) => call(client, request as never),
639
+ async complete() {
640
+ const sponsored = FeePayer.prepareSponsoredTransaction({
641
+ account: feePayer,
642
+ allowedFeeTokens: defaultFeeToken ? [defaultFeeToken] : undefined,
643
+ challengeExpires,
644
+ chainId: client.chain!.id,
645
+ details: { channelId, currency, recipient },
646
+ policy: feePayerPolicy,
647
+ transaction: {
648
+ ...transaction,
649
+ ...(resolvedFeeToken ? { feeToken: resolvedFeeToken } : {}),
650
+ },
651
+ })
652
+ return { feePayer: feePayer.address, transaction: sponsored }
646
653
  },
647
654
  })
648
- return signTransaction(client, sponsored as never)
655
+ return signTransaction(client, completed.transaction as never)
649
656
  }
650
657
  return serializedTransaction
651
- })()
658
+ }
652
659
 
653
660
  if (!waitForConfirmation) {
654
- await call(client, {
655
- ...transaction,
656
- account: transaction.from,
657
- calls,
658
- feePayerSignature: undefined,
659
- } as never)
661
+ const serializedTransaction_final = await completeTransaction()
662
+ // Local sponsorship already ran sender-context preflight above. Every
663
+ // other optimistic path must still simulate before returning calldata as
664
+ // pending on-chain state, including hosted sponsorship (`isSponsored`).
665
+ if (!feePayer)
666
+ await call(
667
+ client,
668
+ FeePayer.simulationTransaction(transaction, { feePayer: isSponsored }) as never,
669
+ )
660
670
  const txHash = await sendRawTransaction(client, {
661
671
  serializedTransaction: serializedTransaction_final as Transaction.TransactionSerializedTempo,
662
672
  })
@@ -669,14 +679,9 @@ export async function broadcastOpenTransaction(
669
679
 
670
680
  let txHash: Hex | undefined
671
681
  try {
672
- if (feePayer)
673
- await call(client, {
674
- ...transaction,
675
- account: transaction.from,
676
- calls,
677
- feePayerSignature: undefined,
678
- } as never)
679
-
682
+ // Keep local preflight inside recovery: a retry can legitimately revert
683
+ // during simulation after the original open was mined.
684
+ const serializedTransaction_final = await completeTransaction()
680
685
  const receipt = await sendRawTransactionSync(client, {
681
686
  serializedTransaction: serializedTransaction_final as Transaction.TransactionSerializedTempo,
682
687
  })
@@ -775,37 +780,36 @@ export async function broadcastTopUpTransaction(
775
780
  })
776
781
 
777
782
  const defaultFeeToken = defaults.currency[client.chain?.id as keyof typeof defaults.currency]
778
- const sponsored = FeePayer.prepareSponsoredTransaction({
779
- account: feePayer,
780
- allowedFeeTokens: defaultFeeToken ? [defaultFeeToken] : undefined,
781
- challengeExpires,
782
- chainId: client.chain!.id,
783
- details: {
784
- additionalDeposit: declaredDeposit.toString(),
785
- channelId,
786
- currency,
787
- },
788
- policy: feePayerPolicy,
789
- transaction: {
790
- ...transaction,
791
- ...((transaction.feeToken ?? defaultFeeToken)
792
- ? { feeToken: transaction.feeToken ?? defaultFeeToken }
793
- : {}),
783
+ const completed = await FeePayer.preflightSponsorship({
784
+ transaction,
785
+ simulate: (request) => call(client, request as never),
786
+ async complete() {
787
+ const sponsored = FeePayer.prepareSponsoredTransaction({
788
+ account: feePayer,
789
+ allowedFeeTokens: defaultFeeToken ? [defaultFeeToken] : undefined,
790
+ challengeExpires,
791
+ chainId: client.chain!.id,
792
+ details: {
793
+ additionalDeposit: declaredDeposit.toString(),
794
+ channelId,
795
+ currency,
796
+ },
797
+ policy: feePayerPolicy,
798
+ transaction: {
799
+ ...transaction,
800
+ ...((transaction.feeToken ?? defaultFeeToken)
801
+ ? { feeToken: transaction.feeToken ?? defaultFeeToken }
802
+ : {}),
803
+ },
804
+ })
805
+ return { feePayer: feePayer.address, transaction: sponsored }
794
806
  },
795
807
  })
796
- return signTransaction(client, sponsored as never)
808
+ return signTransaction(client, completed.transaction as never)
797
809
  }
798
810
  return serializedTransaction
799
811
  })()
800
812
 
801
- if (feePayer)
802
- await call(client, {
803
- ...transaction,
804
- account: transaction.from,
805
- calls,
806
- feePayerSignature: undefined,
807
- } as never)
808
-
809
813
  const receipt = await sendRawTransactionSync(client, {
810
814
  serializedTransaction: serializedTransaction_final as Transaction.TransactionSerializedTempo,
811
815
  })