mppx 0.8.9 → 0.8.12

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 (124) hide show
  1. package/CHANGELOG.md +24 -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/client/internal/Fetch.d.ts.map +1 -1
  22. package/dist/client/internal/Fetch.js +35 -6
  23. package/dist/client/internal/Fetch.js.map +1 -1
  24. package/dist/client/internal/MethodResponse.d.ts +23 -0
  25. package/dist/client/internal/MethodResponse.d.ts.map +1 -0
  26. package/dist/client/internal/MethodResponse.js +15 -0
  27. package/dist/client/internal/MethodResponse.js.map +1 -0
  28. package/dist/tempo/internal/fee-payer.d.ts +39 -2
  29. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  30. package/dist/tempo/internal/fee-payer.js +45 -1
  31. package/dist/tempo/internal/fee-payer.js.map +1 -1
  32. package/dist/tempo/internal/fee-token.d.ts +8 -0
  33. package/dist/tempo/internal/fee-token.d.ts.map +1 -1
  34. package/dist/tempo/internal/fee-token.js +19 -6
  35. package/dist/tempo/internal/fee-token.js.map +1 -1
  36. package/dist/tempo/legacy/client/ChannelOps.d.ts +2 -0
  37. package/dist/tempo/legacy/client/ChannelOps.d.ts.map +1 -1
  38. package/dist/tempo/legacy/client/ChannelOps.js +4 -0
  39. package/dist/tempo/legacy/client/ChannelOps.js.map +1 -1
  40. package/dist/tempo/legacy/client/Session.d.ts.map +1 -1
  41. package/dist/tempo/legacy/client/Session.js +7 -0
  42. package/dist/tempo/legacy/client/Session.js.map +1 -1
  43. package/dist/tempo/legacy/session/Chain.d.ts.map +1 -1
  44. package/dist/tempo/legacy/session/Chain.js +53 -50
  45. package/dist/tempo/legacy/session/Chain.js.map +1 -1
  46. package/dist/tempo/server/Charge.d.ts +8 -0
  47. package/dist/tempo/server/Charge.d.ts.map +1 -1
  48. package/dist/tempo/server/Charge.js +49 -45
  49. package/dist/tempo/server/Charge.js.map +1 -1
  50. package/dist/tempo/server/Methods.d.ts +7 -0
  51. package/dist/tempo/server/Methods.d.ts.map +1 -1
  52. package/dist/tempo/server/Methods.js.map +1 -1
  53. package/dist/tempo/server/Subscription.d.ts.map +1 -1
  54. package/dist/tempo/server/Subscription.js +24 -16
  55. package/dist/tempo/server/Subscription.js.map +1 -1
  56. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  57. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  58. package/dist/tempo/server/internal/html.gen.js +1 -1
  59. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  60. package/dist/tempo/session/client/Session.d.ts.map +1 -1
  61. package/dist/tempo/session/client/Session.js +53 -1
  62. package/dist/tempo/session/client/Session.js.map +1 -1
  63. package/dist/tempo/session/client/SessionManager.d.ts +3 -0
  64. package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
  65. package/dist/tempo/session/client/SessionManager.js +3 -0
  66. package/dist/tempo/session/client/SessionManager.js.map +1 -1
  67. package/dist/tempo/session/client/Transports.d.ts +30 -0
  68. package/dist/tempo/session/client/Transports.d.ts.map +1 -1
  69. package/dist/tempo/session/client/Transports.js +118 -19
  70. package/dist/tempo/session/client/Transports.js.map +1 -1
  71. package/dist/tempo/session/precompile/Chain.d.ts.map +1 -1
  72. package/dist/tempo/session/precompile/Chain.js +25 -19
  73. package/dist/tempo/session/precompile/Chain.js.map +1 -1
  74. package/dist/tempo/session/server/index.d.ts +2 -0
  75. package/dist/tempo/session/server/index.d.ts.map +1 -1
  76. package/dist/tempo/session/server/index.js +2 -0
  77. package/dist/tempo/session/server/index.js.map +1 -1
  78. package/dist/validation/core.d.ts +1 -0
  79. package/dist/validation/core.d.ts.map +1 -1
  80. package/dist/validation/core.js +5 -0
  81. package/dist/validation/core.js.map +1 -1
  82. package/dist/x402/Header.js +1 -1
  83. package/dist/x402/Header.js.map +1 -1
  84. package/package.json +1 -1
  85. package/src/cli/validate/challenge.ts +20 -8
  86. package/src/cli/validate/discovery.test.ts +128 -0
  87. package/src/cli/validate/discovery.ts +50 -15
  88. package/src/cli/validate/helpers.ts +10 -0
  89. package/src/cli/validate/index.ts +4 -0
  90. package/src/cli/validate/payment.ts +4 -1
  91. package/src/cli/validate.test.ts +23 -2
  92. package/src/client/Mppx.test-d.ts +3 -1
  93. package/src/client/internal/Fetch.test.ts +29 -0
  94. package/src/client/internal/Fetch.ts +51 -6
  95. package/src/client/internal/MethodResponse.ts +39 -0
  96. package/src/tempo/PublicExports.test-d.ts +5 -0
  97. package/src/tempo/internal/fee-payer.test.ts +93 -0
  98. package/src/tempo/internal/fee-payer.ts +64 -2
  99. package/src/tempo/internal/fee-token.test.ts +37 -0
  100. package/src/tempo/internal/fee-token.ts +26 -5
  101. package/src/tempo/legacy/client/ChannelOps.test.ts +21 -0
  102. package/src/tempo/legacy/client/ChannelOps.ts +7 -0
  103. package/src/tempo/legacy/client/Session.test.ts +102 -1
  104. package/src/tempo/legacy/client/Session.ts +10 -0
  105. package/src/tempo/legacy/session/Chain.test.ts +78 -2
  106. package/src/tempo/legacy/session/Chain.ts +56 -52
  107. package/src/tempo/server/Charge.test.ts +121 -6
  108. package/src/tempo/server/Charge.ts +61 -48
  109. package/src/tempo/server/Methods.ts +6 -0
  110. package/src/tempo/server/Subscription.test.ts +79 -4
  111. package/src/tempo/server/Subscription.ts +24 -16
  112. package/src/tempo/server/internal/html.gen.ts +1 -1
  113. package/src/tempo/session/client/Session.test.ts +83 -1
  114. package/src/tempo/session/client/Session.ts +73 -1
  115. package/src/tempo/session/client/SessionManager.test.ts +12 -1
  116. package/src/tempo/session/client/SessionManager.ts +5 -0
  117. package/src/tempo/session/client/Transports.test.ts +109 -0
  118. package/src/tempo/session/client/Transports.ts +153 -29
  119. package/src/tempo/session/precompile/Chain.test.ts +9 -0
  120. package/src/tempo/session/precompile/Chain.ts +28 -20
  121. package/src/tempo/session/server/Session.test.ts +23 -3
  122. package/src/tempo/session/server/index.ts +2 -0
  123. package/src/validation/core.ts +6 -0
  124. 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
@@ -340,6 +340,27 @@ describe.runIf(isLocalnet)('tryRecoverChannel', () => {
340
340
  expect(result!.opened).toBe(true)
341
341
  expect(result!.escrowContract).toBe(escrow)
342
342
  expect(result!.chainId).toBe(chain.id)
343
+ // Channel opened with a zero authorizedSigner: the escrow verifies
344
+ // vouchers against the payer, so the recovered authority is the payer.
345
+ expect(result!.authorizedSigner.toLowerCase()).toBe(payer.address.toLowerCase())
346
+ })
347
+
348
+ test('recovers the on-chain authorizedSigner when set', async () => {
349
+ const authorizedSigner = accounts[4].address
350
+ const { channelId: signerChannelId } = await openChannel({
351
+ escrow,
352
+ payer,
353
+ payee,
354
+ token: currency,
355
+ deposit: 10_000_000n,
356
+ salt: Hex.random(32) as `0x${string}`,
357
+ authorizedSigner,
358
+ })
359
+
360
+ const result = await tryRecoverChannel(client, escrow, signerChannelId, chain.id)
361
+
362
+ expect(result).not.toBeUndefined()
363
+ expect(result!.authorizedSigner.toLowerCase()).toBe(authorizedSigner.toLowerCase())
343
364
  })
344
365
 
345
366
  test('returns undefined for non-existent channel', async () => {
@@ -27,6 +27,8 @@ import { signVoucher } from '../session/Voucher.js'
27
27
 
28
28
  /** Cached channel metadata used by the legacy auto-driving session client. */
29
29
  export type ChannelEntry = {
30
+ /** Voucher authority the channel was opened with. */
31
+ authorizedSigner: Address
30
32
  /** Legacy contract-backed channel ID. */
31
33
  channelId: Hex.Hex
32
34
  /** Salt used to derive the channel ID. */
@@ -209,6 +211,7 @@ export async function createOpenPayload(
209
211
 
210
212
  return {
211
213
  entry: {
214
+ authorizedSigner,
212
215
  channelId,
213
216
  salt,
214
217
  cumulativeAmount: initialAmount,
@@ -249,6 +252,10 @@ export async function tryRecoverChannel(
249
252
 
250
253
  if (onChain.deposit > 0n && !onChain.finalized) {
251
254
  return {
255
+ // A zero on-chain authorizedSigner means the escrow verifies vouchers
256
+ // against the payer, so record the payer as the voucher authority.
257
+ authorizedSigner:
258
+ BigInt(onChain.authorizedSigner) === 0n ? onChain.payer : onChain.authorizedSigner,
252
259
  channelId,
253
260
  salt: '0x' as Hex.Hex,
254
261
  cumulativeAmount: onChain.settled,
@@ -1,7 +1,16 @@
1
1
  import { SignatureEnvelope } from 'ox/tempo'
2
- import { type Address, createClient, decodeFunctionData, erc20Abi, type Hex, http } from 'viem'
2
+ import {
3
+ type Address,
4
+ createClient,
5
+ custom,
6
+ decodeFunctionData,
7
+ erc20Abi,
8
+ type Hex,
9
+ http,
10
+ } from 'viem'
3
11
  import { privateKeyToAccount } from 'viem/accounts'
4
12
  import { Account as TempoAccount, Addresses, Transaction, WebCryptoP256 } from 'viem/tempo'
13
+ import { tempoModerato } from 'viem/tempo/chains'
5
14
  import { beforeAll, describe, expect, test } from 'vp/test'
6
15
  import { tempoNetwork } from '~test/config.js'
7
16
  import { deployEscrow, openChannel } from '~test/tempo/legacy/session.js'
@@ -374,6 +383,98 @@ describe('session (pure)', () => {
374
383
  expect(cred.source).toBe(`did:pkh:eip155:42431:${pureAccount.address}`)
375
384
  })
376
385
  })
386
+
387
+ describe('auto-manage channel reuse', () => {
388
+ const payerAccount = privateKeyToAccount(
389
+ '0x59c6995e998f97a5a0044966f09453863d462d2b3f1446a99f0a3d7b5d0f5a0d',
390
+ )
391
+ const signerA = TempoAccount.fromSecp256k1(
392
+ '0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a',
393
+ { access: payerAccount },
394
+ )
395
+ const signerB = TempoAccount.fromSecp256k1(
396
+ '0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6',
397
+ { access: payerAccount },
398
+ )
399
+
400
+ function makeAutoClient(account: typeof signerA) {
401
+ return createClient({
402
+ account,
403
+ chain: tempoModerato,
404
+ transport: custom({
405
+ async request({ method }: { method: string }) {
406
+ if (method === 'eth_chainId') return `0x${tempoModerato.id.toString(16)}`
407
+ if (method === 'eth_fillTransaction')
408
+ return {
409
+ raw: '0x',
410
+ tx: {
411
+ chainId: `0x${tempoModerato.id.toString(16)}`,
412
+ from: payerAccount.address,
413
+ gas: '0x5208',
414
+ input: '0x',
415
+ maxFeePerGas: '0x1',
416
+ maxPriorityFeePerGas: '0x1',
417
+ nonce: '0x0',
418
+ to: escrowAddress,
419
+ type: '0x2',
420
+ value: '0x0',
421
+ },
422
+ }
423
+ throw new Error(`unexpected rpc request: ${method}`)
424
+ },
425
+ }),
426
+ })
427
+ }
428
+
429
+ test('reuses the cached channel while the voucher signer is unchanged', async () => {
430
+ const method = session({
431
+ getClient: () => makeAutoClient(signerA),
432
+ maxDeposit: '10',
433
+ })
434
+
435
+ const first = deserializePayload(
436
+ await method.createCredential({ challenge: makeChallenge(), context: {} }),
437
+ )
438
+ const second = deserializePayload(
439
+ await method.createCredential({ challenge: makeChallenge(), context: {} }),
440
+ )
441
+
442
+ expect(first.payload.action).toBe('open')
443
+ expect(second.payload.action).toBe('voucher')
444
+ expect(second.payload.channelId).toBe(first.payload.channelId)
445
+ })
446
+
447
+ test('opens a new channel instead of reusing when the authorizedSigner changes', async () => {
448
+ let account = signerA
449
+ const method = session({
450
+ getClient: () => makeAutoClient(account),
451
+ maxDeposit: '10',
452
+ })
453
+
454
+ const first = deserializePayload(
455
+ await method.createCredential({ challenge: makeChallenge(), context: {} }),
456
+ )
457
+
458
+ account = signerB
459
+
460
+ const second = deserializePayload(
461
+ await method.createCredential({ challenge: makeChallenge(), context: {} }),
462
+ )
463
+
464
+ expect(first.payload.action).toBe('open')
465
+ if (first.payload.action !== 'open') throw new Error('expected open payload')
466
+ expect(first.payload.authorizedSigner?.toLowerCase()).toBe(
467
+ signerA.accessKeyAddress.toLowerCase(),
468
+ )
469
+
470
+ expect(second.payload.action).toBe('open')
471
+ if (second.payload.action !== 'open') throw new Error('expected open payload')
472
+ expect(second.payload.authorizedSigner?.toLowerCase()).toBe(
473
+ signerB.accessKeyAddress.toLowerCase(),
474
+ )
475
+ expect(second.payload.channelId).not.toBe(first.payload.channelId)
476
+ })
477
+ })
377
478
  })
378
479
 
379
480
  describe.runIf(isLocalnet)('session (on-chain)', () => {
@@ -175,6 +175,16 @@ export function session(parameters: session.Parameters = {}) {
175
175
  }
176
176
  }
177
177
 
178
+ // A channel only admits vouchers from the authorizedSigner it was opened
179
+ // with, so a cached or recovered entry stops being reusable once the
180
+ // resolved signer changes. Fall through to open a fresh channel instead
181
+ // of emitting a voucher the escrow would reject.
182
+ if (
183
+ entry?.opened &&
184
+ entry.authorizedSigner.toLowerCase() !== getAccountSignerAddress(voucherSigner).toLowerCase()
185
+ )
186
+ entry = undefined
187
+
178
188
  let payload: LegacySessionCredentialPayload
179
189
 
180
190
  if (entry?.opened) {
@@ -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 () => {