mppx 0.4.3 → 0.4.5

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 (35) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/server/Mppx.d.ts.map +1 -1
  3. package/dist/server/Mppx.js.map +1 -1
  4. package/dist/tempo/internal/defaults.d.ts +1 -1
  5. package/dist/tempo/internal/defaults.js +1 -1
  6. package/dist/tempo/server/Session.js +3 -3
  7. package/dist/tempo/server/Session.js.map +1 -1
  8. package/dist/tempo/session/Chain.js +2 -2
  9. package/dist/tempo/session/Chain.js.map +1 -1
  10. package/dist/tempo/session/escrow.abi.d.ts +24 -24
  11. package/dist/tempo/session/escrow.abi.js +30 -30
  12. package/dist/tempo/session/escrow.abi.js.map +1 -1
  13. package/dist/viem/Client.d.ts.map +1 -1
  14. package/dist/viem/Client.js +24 -2
  15. package/dist/viem/Client.js.map +1 -1
  16. package/package.json +1 -1
  17. package/src/client/Mppx.test-d.ts +4 -4
  18. package/src/client/internal/Fetch.test-d.ts +2 -2
  19. package/src/client/internal/Fetch.test.ts +57 -1
  20. package/src/server/Mppx.test-d.ts +2 -2
  21. package/src/server/Mppx.ts +7 -9
  22. package/src/tempo/client/ChannelOps.test.ts +5 -1
  23. package/src/tempo/client/Session.test.ts +2 -1
  24. package/src/tempo/internal/defaults.test.ts +1 -1
  25. package/src/tempo/internal/defaults.ts +1 -1
  26. package/src/tempo/server/Session.test.ts +5 -1
  27. package/src/tempo/server/Session.ts +3 -3
  28. package/src/tempo/server/Sse.test.ts +2 -1
  29. package/src/tempo/server/internal/transport.test.ts +2 -1
  30. package/src/tempo/session/Chain.ts +2 -2
  31. package/src/tempo/session/ChannelStore.test.ts +2 -1
  32. package/src/tempo/session/Sse.test.ts +2 -1
  33. package/src/tempo/session/escrow.abi.ts +30 -30
  34. package/src/viem/Client.test.ts +196 -0
  35. package/src/viem/Client.ts +23 -1
@@ -47,9 +47,9 @@ describe('Fetch.from', () => {
47
47
 
48
48
  describe('Fetch.from.RequestInit', () => {
49
49
  test('behavior: has context property typed to method context', () => {
50
- const method = charge()
50
+ const _method = charge()
51
51
 
52
- type Methods = [typeof method]
52
+ type Methods = [typeof _method]
53
53
  type Init = Fetch.from.RequestInit<Methods>
54
54
 
55
55
  expectTypeOf<Init>().toHaveProperty('context')
@@ -1,9 +1,10 @@
1
1
  import { Receipt } from 'mppx'
2
2
  import { tempo } from 'mppx/client'
3
3
  import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
4
- import { createClient } from 'viem'
4
+ import { createClient, defineChain } from 'viem'
5
5
  import { describe, expect, test, vi } from 'vitest'
6
6
  import * as Http from '~test/Http.js'
7
+ import { rpcUrl } from '~test/tempo/prool.js'
7
8
  import { accounts, asset, chain, client, http } from '~test/tempo/viem.js'
8
9
  import * as Fetch from './Fetch.js'
9
10
 
@@ -233,6 +234,61 @@ describe('Fetch.from', () => {
233
234
  httpServer.close()
234
235
  })
235
236
 
237
+ test('behavior: fee payer with plain chain client (no Tempo serializers)', async () => {
238
+ const plainChain = defineChain({
239
+ id: chain.id,
240
+ name: chain.name,
241
+ nativeCurrency: chain.nativeCurrency,
242
+ rpcUrls: chain.rpcUrls,
243
+ })
244
+ const plainClient = createClient({
245
+ account: accounts[1],
246
+ chain: plainChain,
247
+ transport: http(rpcUrl),
248
+ })
249
+
250
+ const serverWithFeePayer = Mppx_server.create({
251
+ methods: [
252
+ tempo_server.charge({
253
+ feePayer: accounts[0],
254
+ getClient: () => client,
255
+ }),
256
+ ],
257
+ realm,
258
+ secretKey,
259
+ })
260
+
261
+ const fetch = Fetch.from({
262
+ methods: [
263
+ tempo.charge({
264
+ account: accounts[1],
265
+ getClient: () => plainClient,
266
+ }),
267
+ ],
268
+ })
269
+
270
+ const httpServer = await Http.createServer(async (req, res) => {
271
+ const result = await Mppx_server.toNodeListener(
272
+ serverWithFeePayer.charge({
273
+ amount: '1',
274
+ currency: asset,
275
+ expires: new Date(Date.now() + 60_000).toISOString(),
276
+ recipient: accounts[0].address,
277
+ }),
278
+ )(req, res)
279
+ if (result.status === 402) return
280
+ res.end('OK')
281
+ })
282
+
283
+ const response = await fetch(httpServer.url)
284
+ expect(response.status).toBe(200)
285
+
286
+ const receipt = Receipt.fromResponse(response)
287
+ expect(receipt.status).toBe('success')
288
+
289
+ httpServer.close()
290
+ })
291
+
236
292
  test('behavior: onChallenge can create credential', async () => {
237
293
  const onChallenge = vi.fn(async (_challenge, { createCredential }) =>
238
294
  createCredential({ account: accounts[1] }),
@@ -122,8 +122,8 @@ describe('Mppx type tests', () => {
122
122
  recipient: '0x02',
123
123
  }
124
124
 
125
- const handler = mppx.compose([alphaMethod, opts])
126
- type HandlerReturn = ReturnType<typeof handler>
125
+ const _handler = mppx.compose([alphaMethod, opts])
126
+ type HandlerReturn = ReturnType<typeof _handler>
127
127
 
128
128
  assertType<Promise<{ status: 402; challenge: Response } | { status: 200; withReceipt: any }>>(
129
129
  {} as Awaited<HandlerReturn> as any,
@@ -82,16 +82,14 @@ type EffectiveTransportOf<mi, defaultTransport extends Transport.AnyTransport> =
82
82
  : TransportOverrideOf<mi>
83
83
 
84
84
  /** True when exactly one method has the given intent (no name collision). */
85
- type IsUniqueIntent<methods extends readonly Method.AnyServer[], intent extends string> = Extract<
86
- methods[number],
87
- { intent: intent }
88
- > extends infer M
89
- ? M extends M
90
- ? [Exclude<Extract<methods[number], { intent: intent }>, M>] extends [never]
91
- ? true
92
- : false
85
+ type IsUniqueIntent<methods extends readonly Method.AnyServer[], intent extends string> =
86
+ Extract<methods[number], { intent: intent }> extends infer M
87
+ ? M extends M
88
+ ? [Exclude<Extract<methods[number], { intent: intent }>, M>] extends [never]
89
+ ? true
90
+ : false
91
+ : never
93
92
  : never
94
- : never
95
93
 
96
94
  /** Only includes shorthand intent keys when the intent is unique across methods. */
97
95
  type UniqueIntentHandlers<
@@ -7,6 +7,10 @@ import { deployEscrow, openChannel } from '~test/tempo/session.js'
7
7
  import { accounts, asset, chain, client, fundAccount, http } from '~test/tempo/viem.js'
8
8
  import type { Challenge } from '../../Challenge.js'
9
9
  import * as Credential from '../../Credential.js'
10
+ import {
11
+ chainId as chainIdDefaults,
12
+ escrowContract as escrowContractDefaults,
13
+ } from '../internal/defaults.js'
10
14
  import { verifyVoucher } from '../session/Voucher.js'
11
15
  import {
12
16
  createClosePayload,
@@ -17,7 +21,7 @@ import {
17
21
  tryRecoverChannel,
18
22
  } from './ChannelOps.js'
19
23
 
20
- const escrow42431 = '0x542831e3E4Ace07559b7C8787395f4Fb99F70787' as Address
24
+ const escrow42431 = escrowContractDefaults[chainIdDefaults.testnet] as Address
21
25
 
22
26
  const localAccount = privateKeyToAccount(
23
27
  '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80',
@@ -6,6 +6,7 @@ import { deployEscrow, openChannel } from '~test/tempo/session.js'
6
6
  import { accounts, asset, chain, client, fundAccount } from '~test/tempo/viem.js'
7
7
  import * as Challenge from '../../Challenge.js'
8
8
  import * as Credential from '../../Credential.js'
9
+ import { chainId, escrowContract as escrowContractDefaults } from '../internal/defaults.js'
9
10
  import type { SessionCredentialPayload } from '../session/Types.js'
10
11
  import { session } from './Session.js'
11
12
 
@@ -22,7 +23,7 @@ const pureClient = createClient({
22
23
  transport: http('http://127.0.0.1'),
23
24
  })
24
25
 
25
- const escrowAddress = '0x542831e3E4Ace07559b7C8787395f4Fb99F70787' as Address
26
+ const escrowAddress = escrowContractDefaults[chainId.testnet] as Address
26
27
  const recipient = '0x2222222222222222222222222222222222222222' as Address
27
28
  const currency = '0x3333333333333333333333333333333333333333' as Address
28
29
 
@@ -53,7 +53,7 @@ describe('escrowContract', () => {
53
53
  })
54
54
 
55
55
  test('testnet escrow contract', () => {
56
- expect(escrowContract[chainId.testnet]).toBe('0x542831e3E4Ace07559b7C8787395f4Fb99F70787')
56
+ expect(escrowContract[chainId.testnet]).toBe('0xe1c4d3dce17bc111181ddf716f75bae49e61a336')
57
57
  })
58
58
  })
59
59
 
@@ -32,7 +32,7 @@ export const decimals = 6
32
32
  /** Default payment-channel escrow contract addresses per chain. */
33
33
  export const escrowContract = {
34
34
  [chainId.mainnet]: '0x33b901018174DDabE4841042ab76ba85D4e24f25',
35
- [chainId.testnet]: '0x542831e3E4Ace07559b7C8787395f4Fb99F70787',
35
+ [chainId.testnet]: '0xe1c4d3dce17bc111181ddf716f75bae49e61a336',
36
36
  } as const satisfies Record<ChainId, string>
37
37
 
38
38
  /** Default RPC URLs for each Tempo chain. */
@@ -18,6 +18,10 @@ import {
18
18
  InvalidSignatureError,
19
19
  } from '../../Errors.js'
20
20
  import * as Store from '../../Store.js'
21
+ import {
22
+ chainId as chainIdDefaults,
23
+ escrowContract as escrowContractDefaults,
24
+ } from '../internal/defaults.js'
21
25
  import type * as Methods from '../Methods.js'
22
26
  import * as ChannelStore from '../session/ChannelStore.js'
23
27
  import type { SessionReceipt } from '../session/Types.js'
@@ -1309,7 +1313,7 @@ describe('monotonicity and TOCTOU (unit tests)', () => {
1309
1313
  token: '0x0000000000000000000000000000000000000003' as Address,
1310
1314
  authorizedSigner: '0x0000000000000000000000000000000000000004' as Address,
1311
1315
  chainId: 42431,
1312
- escrowContract: '0x542831e3E4Ace07559b7C8787395f4Fb99F70787' as Address,
1316
+ escrowContract: escrowContractDefaults[chainIdDefaults.testnet] as Address,
1313
1317
  deposit: 10000000n,
1314
1318
  settledOnChain: 0n,
1315
1319
  highestVoucherAmount: 5000000n,
@@ -723,14 +723,14 @@ async function handleVoucher(
723
723
  lastOnChainVerified.set(payload.channelId, Date.now())
724
724
  } else {
725
725
  cachedOnChain = {
726
+ finalized: channel.finalized,
727
+ closeRequestedAt: 0n,
726
728
  payer: channel.payer,
727
729
  payee: channel.payee,
728
730
  token: channel.token,
731
+ authorizedSigner: channel.authorizedSigner,
729
732
  deposit: channel.deposit,
730
733
  settled: channel.settledOnChain,
731
- finalized: channel.finalized,
732
- authorizedSigner: channel.authorizedSigner,
733
- closeRequestedAt: 0n,
734
734
  }
735
735
  }
736
736
 
@@ -1,5 +1,6 @@
1
1
  import type { Address, Hex } from 'viem'
2
2
  import { describe, expect, test } from 'vitest'
3
+ import { chainId, escrowContract as escrowContractDefaults } from '../internal/defaults.js'
3
4
  import type * as ChannelStore from '../session/ChannelStore.js'
4
5
  import { serve, toResponse } from '../session/Sse.js'
5
6
 
@@ -33,7 +34,7 @@ function seedChannel(
33
34
  token: '0x0000000000000000000000000000000000000003' as Address,
34
35
  authorizedSigner: '0x0000000000000000000000000000000000000004' as Address,
35
36
  chainId: 42431,
36
- escrowContract: '0x542831e3E4Ace07559b7C8787395f4Fb99F70787' as Address,
37
+ escrowContract: escrowContractDefaults[chainId.testnet] as Address,
37
38
  deposit: balance,
38
39
  settledOnChain: 0n,
39
40
  highestVoucherAmount: balance,
@@ -2,6 +2,7 @@ import { Challenge, Credential } from 'mppx'
2
2
  import type { Address, Hex } from 'viem'
3
3
  import { describe, expect, test } from 'vitest'
4
4
  import * as Store from '../../../Store.js'
5
+ import { chainId, escrowContract as escrowContractDefaults } from '../../internal/defaults.js'
5
6
  import * as ChannelStore from '../../session/ChannelStore.js'
6
7
  import { sse } from './transport.js'
7
8
 
@@ -23,7 +24,7 @@ function seedChannel(
23
24
  token: '0x0000000000000000000000000000000000000003' as Address,
24
25
  authorizedSigner: '0x0000000000000000000000000000000000000004' as Address,
25
26
  chainId: 42431,
26
- escrowContract: '0x542831e3E4Ace07559b7C8787395f4Fb99F70787' as Address,
27
+ escrowContract: escrowContractDefaults[chainId.testnet] as Address,
27
28
  deposit: balance,
28
29
  settledOnChain: 0n,
29
30
  highestVoucherAmount: balance,
@@ -305,14 +305,14 @@ export async function broadcastOpenTransaction(parameters: {
305
305
  return {
306
306
  txHash,
307
307
  onChain: {
308
+ finalized: false,
309
+ closeRequestedAt: 0n,
308
310
  payer: transaction.from,
309
311
  payee,
310
312
  token,
311
313
  authorizedSigner,
312
314
  deposit,
313
315
  settled: 0n,
314
- closeRequestedAt: 0n,
315
- finalized: false,
316
316
  } as OnChainChannel,
317
317
  }
318
318
  }
@@ -1,6 +1,7 @@
1
1
  import type { Address, Hex } from 'viem'
2
2
  import { describe, expect, test } from 'vitest'
3
3
  import * as Store from '../../Store.js'
4
+ import { chainId, escrowContract as escrowContractDefaults } from '../internal/defaults.js'
4
5
  import * as ChannelStore from './ChannelStore.js'
5
6
 
6
7
  const channelId = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex
@@ -14,7 +15,7 @@ function makeChannel(overrides?: Partial<ChannelStore.State>): ChannelStore.Stat
14
15
  token: '0x0000000000000000000000000000000000000003' as Address,
15
16
  authorizedSigner: '0x0000000000000000000000000000000000000004' as Address,
16
17
  chainId: 42431,
17
- escrowContract: '0x542831e3E4Ace07559b7C8787395f4Fb99F70787' as Address,
18
+ escrowContract: escrowContractDefaults[chainId.testnet] as Address,
18
19
  deposit: 10_000_000n,
19
20
  settledOnChain: 0n,
20
21
  highestVoucherAmount: 10_000_000n,
@@ -1,5 +1,6 @@
1
1
  import type { Address, Hex } from 'viem'
2
2
  import { describe, expect, test } from 'vitest'
3
+ import { chainId, escrowContract as escrowContractDefaults } from '../internal/defaults.js'
3
4
  import type * as ChannelStore from './ChannelStore.js'
4
5
  import { formatNeedVoucherEvent, formatReceiptEvent, parseEvent, serve } from './Sse.js'
5
6
  import type { NeedVoucherEvent, SessionReceipt } from './Types.js'
@@ -218,7 +219,7 @@ describe('serve', () => {
218
219
  token: '0x0000000000000000000000000000000000000003' as Address,
219
220
  authorizedSigner: '0x0000000000000000000000000000000000000004' as Address,
220
221
  chainId: 42431,
221
- escrowContract: '0x542831e3E4Ace07559b7C8787395f4Fb99F70787' as Address,
222
+ escrowContract: escrowContractDefaults[chainId.testnet] as Address,
222
223
  deposit: balance,
223
224
  settledOnChain: 0n,
224
225
  highestVoucherAmount: balance,
@@ -36,6 +36,16 @@ export const escrowAbi = [
36
36
  },
37
37
  ],
38
38
  outputs: [
39
+ {
40
+ name: 'finalized',
41
+ type: 'bool',
42
+ internalType: 'bool',
43
+ },
44
+ {
45
+ name: 'closeRequestedAt',
46
+ type: 'uint64',
47
+ internalType: 'uint64',
48
+ },
39
49
  {
40
50
  name: 'payer',
41
51
  type: 'address',
@@ -66,16 +76,6 @@ export const escrowAbi = [
66
76
  type: 'uint128',
67
77
  internalType: 'uint128',
68
78
  },
69
- {
70
- name: 'closeRequestedAt',
71
- type: 'uint64',
72
- internalType: 'uint64',
73
- },
74
- {
75
- name: 'finalized',
76
- type: 'bool',
77
- internalType: 'bool',
78
- },
79
79
  ],
80
80
  stateMutability: 'view',
81
81
  },
@@ -213,6 +213,16 @@ export const escrowAbi = [
213
213
  type: 'tuple',
214
214
  internalType: 'struct TempoStreamChannel.Channel',
215
215
  components: [
216
+ {
217
+ name: 'finalized',
218
+ type: 'bool',
219
+ internalType: 'bool',
220
+ },
221
+ {
222
+ name: 'closeRequestedAt',
223
+ type: 'uint64',
224
+ internalType: 'uint64',
225
+ },
216
226
  {
217
227
  name: 'payer',
218
228
  type: 'address',
@@ -243,16 +253,6 @@ export const escrowAbi = [
243
253
  type: 'uint128',
244
254
  internalType: 'uint128',
245
255
  },
246
- {
247
- name: 'closeRequestedAt',
248
- type: 'uint64',
249
- internalType: 'uint64',
250
- },
251
- {
252
- name: 'finalized',
253
- type: 'bool',
254
- internalType: 'bool',
255
- },
256
256
  ],
257
257
  },
258
258
  ],
@@ -274,6 +274,16 @@ export const escrowAbi = [
274
274
  type: 'tuple[]',
275
275
  internalType: 'struct TempoStreamChannel.Channel[]',
276
276
  components: [
277
+ {
278
+ name: 'finalized',
279
+ type: 'bool',
280
+ internalType: 'bool',
281
+ },
282
+ {
283
+ name: 'closeRequestedAt',
284
+ type: 'uint64',
285
+ internalType: 'uint64',
286
+ },
277
287
  {
278
288
  name: 'payer',
279
289
  type: 'address',
@@ -304,16 +314,6 @@ export const escrowAbi = [
304
314
  type: 'uint128',
305
315
  internalType: 'uint128',
306
316
  },
307
- {
308
- name: 'closeRequestedAt',
309
- type: 'uint64',
310
- internalType: 'uint64',
311
- },
312
- {
313
- name: 'finalized',
314
- type: 'bool',
315
- internalType: 'bool',
316
- },
317
317
  ],
318
318
  },
319
319
  ],
@@ -1,3 +1,8 @@
1
+ import { createClient, custom, defineChain, type Hex } from 'viem'
2
+ import { privateKeyToAccount } from 'viem/accounts'
3
+ import { signTransaction } from 'viem/actions'
4
+ import { tempoLocalnet } from 'viem/chains'
5
+ import { Transaction } from 'viem/tempo'
1
6
  import { describe, expect, test } from 'vitest'
2
7
  import * as Client from './Client.js'
3
8
 
@@ -56,3 +61,194 @@ describe('getResolver', () => {
56
61
  )
57
62
  })
58
63
  })
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Serializer injection – ensures user-provided clients without Tempo chain
67
+ // config get Tempo serializers merged in by getResolver.
68
+ // ---------------------------------------------------------------------------
69
+
70
+ const testAccount = privateKeyToAccount(
71
+ '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80',
72
+ )
73
+
74
+ const chainIdHex = `0x${tempoLocalnet.id.toString(16)}`
75
+ const mockTransport = custom({
76
+ async request({ method }: { method: string }) {
77
+ if (method === 'eth_chainId') return chainIdHex
78
+ throw new Error(`Unexpected RPC call: ${method}`)
79
+ },
80
+ })
81
+
82
+ const tempoClient = createClient({
83
+ account: testAccount,
84
+ chain: tempoLocalnet,
85
+ transport: mockTransport,
86
+ })
87
+
88
+ function createPlainClient() {
89
+ const plainChain = defineChain({
90
+ id: tempoLocalnet.id,
91
+ name: 'Tempo (no serializer)',
92
+ nativeCurrency: { name: 'ETH', symbol: 'ETH', decimals: 18 },
93
+ rpcUrls: { default: { http: ['http://127.0.0.1:1'] } },
94
+ })
95
+ return createClient({
96
+ account: testAccount,
97
+ chain: plainChain,
98
+ transport: mockTransport,
99
+ })
100
+ }
101
+
102
+ const feePayer_prepared = {
103
+ chainId: tempoLocalnet.id,
104
+ calls: [
105
+ {
106
+ to: '0x20c0000000000000000000000000000000000001' as const,
107
+ data: '0x1234' as Hex,
108
+ },
109
+ ],
110
+ feePayer: true as const,
111
+ feeToken: '0x20c0000000000000000000000000000000000001' as const,
112
+ gas: 100_000n,
113
+ maxFeePerGas: 1_000_000_000n,
114
+ maxPriorityFeePerGas: 1_000_000n,
115
+ nonce: 0,
116
+ nonceKey: 115792089237316195423570985008687907853269984665640564039457584007913129639935n,
117
+ validBefore: Math.floor(Date.now() / 1000) + 25,
118
+ }
119
+
120
+ describe('feePayer transaction serialization', () => {
121
+ test('behavior: signTransaction with Tempo chain and feePayer: true', async () => {
122
+ const serialized = await signTransaction(tempoClient, {
123
+ account: testAccount,
124
+ ...feePayer_prepared,
125
+ } as never)
126
+ expect(serialized).toMatch(/^0x7[68]/)
127
+ })
128
+
129
+ test('behavior: signTransaction with gasPrice + type legacy', async () => {
130
+ const serialized = await signTransaction(tempoClient, {
131
+ account: testAccount,
132
+ ...feePayer_prepared,
133
+ gasPrice: 1_000_000_000n,
134
+ type: 'legacy' as const,
135
+ } as never)
136
+ expect(serialized).toMatch(/^0x7[68]/)
137
+ })
138
+
139
+ test('behavior: fee-payer co-sign with Account object', async () => {
140
+ const feePayerAccount = privateKeyToAccount(
141
+ '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d',
142
+ )
143
+ const serialized = await signTransaction(tempoClient, {
144
+ account: feePayerAccount,
145
+ ...feePayer_prepared,
146
+ feePayer: feePayerAccount,
147
+ } as never)
148
+ expect(serialized).toMatch(/^0x7[68]/)
149
+ })
150
+
151
+ test('behavior: deserialized + re-signed tx (server charge flow)', async () => {
152
+ const feePayerAccount = privateKeyToAccount(
153
+ '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d',
154
+ )
155
+ const clientSigned = await signTransaction(tempoClient, {
156
+ account: testAccount,
157
+ ...feePayer_prepared,
158
+ } as never)
159
+ const deserialized = Transaction.deserialize(
160
+ clientSigned as Transaction.TransactionSerializedTempo,
161
+ )
162
+ const serverSigned = await signTransaction(tempoClient, {
163
+ ...deserialized,
164
+ account: feePayerAccount,
165
+ feePayer: feePayerAccount,
166
+ feeToken: '0x20c0000000000000000000000000000000000001' as const,
167
+ } as never)
168
+ expect(serverSigned).toMatch(/^0x7[68]/)
169
+ })
170
+ })
171
+
172
+ describe('getResolver serializer injection', () => {
173
+ test('behavior: injects Tempo serializer onto plain clients', async () => {
174
+ const plainClient = createPlainClient()
175
+ expect(plainClient.chain?.serializers?.transaction).toBeUndefined()
176
+
177
+ const resolver = Client.getResolver({
178
+ chain: tempoLocalnet,
179
+ getClient: () => plainClient,
180
+ })
181
+ const resolvedClient = await resolver({})
182
+
183
+ expect(resolvedClient.chain?.serializers?.transaction).toBeDefined()
184
+ const serialized = await signTransaction(resolvedClient, {
185
+ account: testAccount,
186
+ ...feePayer_prepared,
187
+ } as never)
188
+ expect(serialized).toMatch(/^0x7[68]/)
189
+ })
190
+
191
+ test('behavior: fixes legacy type + feePayer + maxFeePerGas', async () => {
192
+ const resolver = Client.getResolver({
193
+ chain: tempoLocalnet,
194
+ getClient: () => createPlainClient(),
195
+ })
196
+ const resolvedClient = await resolver({})
197
+
198
+ const serialized = await signTransaction(resolvedClient, {
199
+ account: testAccount,
200
+ ...feePayer_prepared,
201
+ type: 'legacy' as const,
202
+ } as never)
203
+ expect(serialized).toMatch(/^0x7[68]/)
204
+ })
205
+
206
+ test('behavior: preserves existing serializers', async () => {
207
+ const resolver = Client.getResolver({
208
+ chain: tempoLocalnet,
209
+ getClient: () => tempoClient,
210
+ })
211
+ const resolvedClient = await resolver({})
212
+
213
+ expect(resolvedClient.chain?.serializers?.transaction).toBe(
214
+ tempoClient.chain?.serializers?.transaction,
215
+ )
216
+ })
217
+
218
+ test('behavior: passes through getClient when chain has no serializers', async () => {
219
+ const getClient = () => createPlainClient()
220
+ const resolver = Client.getResolver({
221
+ chain: undefined,
222
+ getClient,
223
+ })
224
+
225
+ expect(resolver).toBe(getClient)
226
+ })
227
+
228
+ test('behavior: does not mutate the original client', async () => {
229
+ const plainClient = createPlainClient()
230
+ const originalChain = plainClient.chain
231
+
232
+ const resolver = Client.getResolver({
233
+ chain: tempoLocalnet,
234
+ getClient: () => plainClient,
235
+ })
236
+ const resolvedClient = await resolver({})
237
+
238
+ expect(plainClient.chain).toBe(originalChain)
239
+ expect(resolvedClient).not.toBe(plainClient)
240
+ expect(resolvedClient.chain?.serializers?.transaction).toBeDefined()
241
+ })
242
+
243
+ test('error: plain client without resolver throws on feePayer tx', async () => {
244
+ const plainClient = createPlainClient()
245
+
246
+ await expect(
247
+ signTransaction(plainClient, {
248
+ account: testAccount,
249
+ ...feePayer_prepared,
250
+ type: 'legacy' as const,
251
+ } as never),
252
+ ).rejects.toThrow()
253
+ })
254
+ })
@@ -11,7 +11,29 @@ export function getResolver(
11
11
  ): (parameters: { chainId?: number | undefined }) => MaybePromise<Client> {
12
12
  const { chain, getClient, rpcUrl } = parameters
13
13
 
14
- if (getClient) return getClient
14
+ if (getClient) {
15
+ // When a default chain with serializers is provided (e.g. Tempo chain config),
16
+ // ensure user-provided clients inherit those serializers. Without this, clients
17
+ // created without the Tempo chain config will use the default viem serializer,
18
+ // causing errors like "maxFeePerGas is not a valid Legacy Transaction attribute".
19
+ if (!chain?.serializers) return getClient
20
+ return async (params) => {
21
+ const client = await getClient(params)
22
+ if (client.chain?.serializers?.transaction) return client
23
+ return Object.assign({}, client, {
24
+ chain: {
25
+ ...chain,
26
+ ...client.chain,
27
+ formatters: client.chain?.formatters ?? chain.formatters,
28
+ prepareTransactionRequest:
29
+ client.chain?.prepareTransactionRequest ?? chain.prepareTransactionRequest,
30
+ serializers: client.chain?.serializers?.transaction
31
+ ? client.chain.serializers
32
+ : chain.serializers,
33
+ } as typeof client.chain,
34
+ })
35
+ }
36
+ }
15
37
 
16
38
  return ({ chainId }: { chainId?: number | undefined }) => {
17
39
  if (!rpcUrl) throw new Error('No `rpcUrl` provided.')