mppx 0.5.7 → 0.5.9

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 (102) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/Challenge.d.ts +3 -2
  3. package/dist/Challenge.d.ts.map +1 -1
  4. package/dist/Challenge.js +27 -9
  5. package/dist/Challenge.js.map +1 -1
  6. package/dist/Method.d.ts +32 -14
  7. package/dist/Method.d.ts.map +1 -1
  8. package/dist/Method.js.map +1 -1
  9. package/dist/Store.d.ts +68 -2
  10. package/dist/Store.d.ts.map +1 -1
  11. package/dist/Store.js +41 -4
  12. package/dist/Store.js.map +1 -1
  13. package/dist/mcp-sdk/server/Transport.d.ts.map +1 -1
  14. package/dist/mcp-sdk/server/Transport.js +7 -0
  15. package/dist/mcp-sdk/server/Transport.js.map +1 -1
  16. package/dist/server/Mppx.d.ts +1 -1
  17. package/dist/server/Mppx.d.ts.map +1 -1
  18. package/dist/server/Mppx.js +133 -70
  19. package/dist/server/Mppx.js.map +1 -1
  20. package/dist/server/Transport.d.ts +8 -2
  21. package/dist/server/Transport.d.ts.map +1 -1
  22. package/dist/server/Transport.js +26 -1
  23. package/dist/server/Transport.js.map +1 -1
  24. package/dist/tempo/client/SessionManager.d.ts +13 -2
  25. package/dist/tempo/client/SessionManager.d.ts.map +1 -1
  26. package/dist/tempo/client/SessionManager.js +429 -4
  27. package/dist/tempo/client/SessionManager.js.map +1 -1
  28. package/dist/tempo/internal/fee-payer.d.ts +28 -0
  29. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  30. package/dist/tempo/internal/fee-payer.js +89 -0
  31. package/dist/tempo/internal/fee-payer.js.map +1 -1
  32. package/dist/tempo/server/Charge.d.ts +4 -1
  33. package/dist/tempo/server/Charge.d.ts.map +1 -1
  34. package/dist/tempo/server/Charge.js +90 -66
  35. package/dist/tempo/server/Charge.js.map +1 -1
  36. package/dist/tempo/server/Methods.d.ts +3 -0
  37. package/dist/tempo/server/Methods.d.ts.map +1 -1
  38. package/dist/tempo/server/Methods.js +3 -0
  39. package/dist/tempo/server/Methods.js.map +1 -1
  40. package/dist/tempo/server/Session.d.ts +8 -2
  41. package/dist/tempo/server/Session.d.ts.map +1 -1
  42. package/dist/tempo/server/Session.js.map +1 -1
  43. package/dist/tempo/server/index.d.ts +1 -0
  44. package/dist/tempo/server/index.d.ts.map +1 -1
  45. package/dist/tempo/server/index.js +1 -0
  46. package/dist/tempo/server/index.js.map +1 -1
  47. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  48. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  49. package/dist/tempo/server/internal/html.gen.js +1 -1
  50. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  51. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  52. package/dist/tempo/server/internal/transport.js +16 -6
  53. package/dist/tempo/server/internal/transport.js.map +1 -1
  54. package/dist/tempo/session/ChannelStore.d.ts +12 -1
  55. package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
  56. package/dist/tempo/session/ChannelStore.js +55 -14
  57. package/dist/tempo/session/ChannelStore.js.map +1 -1
  58. package/dist/tempo/session/Sse.d.ts +11 -2
  59. package/dist/tempo/session/Sse.d.ts.map +1 -1
  60. package/dist/tempo/session/Sse.js +66 -25
  61. package/dist/tempo/session/Sse.js.map +1 -1
  62. package/dist/tempo/session/Ws.d.ts +87 -0
  63. package/dist/tempo/session/Ws.d.ts.map +1 -0
  64. package/dist/tempo/session/Ws.js +428 -0
  65. package/dist/tempo/session/Ws.js.map +1 -0
  66. package/dist/tempo/session/index.d.ts +1 -0
  67. package/dist/tempo/session/index.d.ts.map +1 -1
  68. package/dist/tempo/session/index.js +1 -0
  69. package/dist/tempo/session/index.js.map +1 -1
  70. package/package.json +2 -2
  71. package/src/Challenge.test.ts +1 -1
  72. package/src/Challenge.ts +28 -9
  73. package/src/Method.ts +61 -20
  74. package/src/Store.test-d.ts +80 -2
  75. package/src/Store.test.ts +150 -13
  76. package/src/Store.ts +140 -3
  77. package/src/mcp-sdk/server/Transport.test.ts +12 -0
  78. package/src/mcp-sdk/server/Transport.ts +8 -0
  79. package/src/server/Mppx.test.ts +105 -0
  80. package/src/server/Mppx.ts +178 -88
  81. package/src/server/Transport.test.ts +31 -0
  82. package/src/server/Transport.ts +31 -2
  83. package/src/tempo/client/SessionManager.ts +510 -7
  84. package/src/tempo/internal/fee-payer.test.ts +115 -1
  85. package/src/tempo/internal/fee-payer.ts +138 -1
  86. package/src/tempo/server/AtomicStore.test-d.ts +34 -0
  87. package/src/tempo/server/Charge.test.ts +128 -0
  88. package/src/tempo/server/Charge.ts +118 -93
  89. package/src/tempo/server/Methods.ts +3 -0
  90. package/src/tempo/server/Session.test.ts +1044 -47
  91. package/src/tempo/server/Session.ts +8 -2
  92. package/src/tempo/server/Sse.test.ts +29 -0
  93. package/src/tempo/server/index.ts +1 -0
  94. package/src/tempo/server/internal/html/main.ts +9 -10
  95. package/src/tempo/server/internal/html.gen.ts +1 -1
  96. package/src/tempo/server/internal/transport.ts +19 -6
  97. package/src/tempo/session/ChannelStore.test.ts +20 -1
  98. package/src/tempo/session/ChannelStore.ts +77 -14
  99. package/src/tempo/session/Sse.ts +77 -24
  100. package/src/tempo/session/Ws.test.ts +410 -0
  101. package/src/tempo/session/Ws.ts +563 -0
  102. package/src/tempo/session/index.ts +1 -0
@@ -2,11 +2,17 @@ import { encodeFunctionData } from 'viem'
2
2
  import { Abis, Addresses } from 'viem/tempo'
3
3
  import { describe, expect, test } from 'vp/test'
4
4
 
5
- import { callScopes, FeePayerValidationError, validateCalls } from './fee-payer.js'
5
+ import {
6
+ callScopes,
7
+ FeePayerValidationError,
8
+ prepareSponsoredTransaction,
9
+ validateCalls,
10
+ } from './fee-payer.js'
6
11
  import * as Selectors from './selectors.js'
7
12
 
8
13
  const details = { amount: '1', currency: '0x01', recipient: '0x02' }
9
14
  const bogus = '0x0000000000000000000000000000000000000001' as const
15
+ const sponsor = { address: bogus, type: 'local' } as any
10
16
 
11
17
  describe('callScopes', () => {
12
18
  test('has 4 allowed patterns', () => {
@@ -241,3 +247,111 @@ describe('validateCalls', () => {
241
247
  ).toThrow('disallowed call pattern')
242
248
  })
243
249
  })
250
+
251
+ describe('prepareSponsoredTransaction', () => {
252
+ const baseTransaction = {
253
+ accessList: [],
254
+ calls: [
255
+ {
256
+ data: encodeFunctionData({
257
+ abi: Abis.tip20,
258
+ functionName: 'transfer',
259
+ args: [bogus, 100n],
260
+ }),
261
+ to: bogus,
262
+ },
263
+ ],
264
+ chainId: 42431,
265
+ feeToken: bogus,
266
+ from: bogus,
267
+ gas: 150_000n,
268
+ maxFeePerGas: 1_000_000_000n,
269
+ maxPriorityFeePerGas: 1_000_000_000n,
270
+ nonce: 1n,
271
+ nonceKey: 1n,
272
+ signature: { r: 1n, s: 1n, yParity: 0 } as any,
273
+ validBefore: Math.floor(Date.now() / 1_000) + 300,
274
+ } as const
275
+
276
+ test('accepts bounded sponsored transaction fields', () => {
277
+ expect(() =>
278
+ prepareSponsoredTransaction({
279
+ account: sponsor,
280
+ chainId: 42431,
281
+ details,
282
+ expectedFeeToken: bogus,
283
+ transaction: baseTransaction as any,
284
+ }),
285
+ ).not.toThrow()
286
+ })
287
+
288
+ test('drops unknown top-level fields from the sponsored transaction', () => {
289
+ const sponsored = prepareSponsoredTransaction({
290
+ account: sponsor,
291
+ chainId: 42431,
292
+ details,
293
+ expectedFeeToken: bogus,
294
+ transaction: { ...baseTransaction, unexpectedField: 'ignored' } as any,
295
+ }) as Record<string, unknown>
296
+
297
+ expect(sponsored.unexpectedField).toBeUndefined()
298
+ })
299
+
300
+ test('error: rejects excessive maxFeePerGas', () => {
301
+ expect(() =>
302
+ prepareSponsoredTransaction({
303
+ account: sponsor,
304
+ chainId: 42431,
305
+ details,
306
+ expectedFeeToken: bogus,
307
+ transaction: {
308
+ ...baseTransaction,
309
+ maxFeePerGas: 200_000_000_000n,
310
+ } as any,
311
+ }),
312
+ ).toThrow('maxFeePerGas exceeds sponsor policy')
313
+ })
314
+
315
+ test('error: rejects combined gas and fee budget outside policy', () => {
316
+ expect(() =>
317
+ prepareSponsoredTransaction({
318
+ account: sponsor,
319
+ chainId: 42431,
320
+ details,
321
+ expectedFeeToken: bogus,
322
+ transaction: {
323
+ ...baseTransaction,
324
+ gas: 1_500_000n,
325
+ maxFeePerGas: 10_000_000_000n,
326
+ } as any,
327
+ }),
328
+ ).toThrow('total fee budget exceeds sponsor policy')
329
+ })
330
+
331
+ test('error: rejects mismatched feeToken', () => {
332
+ expect(() =>
333
+ prepareSponsoredTransaction({
334
+ account: sponsor,
335
+ chainId: 42431,
336
+ details,
337
+ expectedFeeToken: '0x0000000000000000000000000000000000000002',
338
+ transaction: baseTransaction as any,
339
+ }),
340
+ ).toThrow('feeToken is not allowed')
341
+ })
342
+
343
+ test('error: rejects long-lived sponsored transactions', () => {
344
+ expect(() =>
345
+ prepareSponsoredTransaction({
346
+ account: sponsor,
347
+ chainId: 42431,
348
+ details,
349
+ expectedFeeToken: bogus,
350
+ transaction: {
351
+ ...baseTransaction,
352
+ validBefore: Math.floor(Date.now() / 1_000) + 3_600,
353
+ } as any,
354
+ }),
355
+ ).toThrow('validity window exceeds sponsor policy')
356
+ })
357
+ })
@@ -1,7 +1,8 @@
1
1
  import type { TempoAddress } from 'ox/tempo'
2
2
  import { TxEnvelopeTempo } from 'ox/tempo'
3
+ import type { Account } from 'viem'
3
4
  import { decodeFunctionData } from 'viem'
4
- import { Abis, Addresses } from 'viem/tempo'
5
+ import { Abis, Addresses, Transaction } from 'viem/tempo'
5
6
 
6
7
  import * as TempoAddress_internal from './address.js'
7
8
  import * as Selectors from './selectors.js'
@@ -25,6 +26,14 @@ export const callScopes = [
25
26
  [Selectors.approve, Selectors.swapExactAmountOut, Selectors.transferWithMemo],
26
27
  ]
27
28
 
29
+ const policy = {
30
+ maxGas: 2_000_000n,
31
+ maxFeePerGas: 100_000_000_000n,
32
+ maxPriorityFeePerGas: 10_000_000_000n,
33
+ maxTotalFee: 10_000_000_000_000_000n,
34
+ maxValidityWindowSeconds: 15 * 60,
35
+ } as const
36
+
28
37
  /** Validates that a set of transaction calls matches an allowed fee-payer pattern. */
29
38
  export function validateCalls(
30
39
  calls: readonly { data?: `0x${string}` | undefined; to?: TempoAddress.Address | undefined }[],
@@ -69,6 +78,134 @@ export function validateCalls(
69
78
  throw new FeePayerValidationError('buy target is not the DEX', details)
70
79
  }
71
80
 
81
+ export function prepareSponsoredTransaction(parameters: {
82
+ account: Account
83
+ challengeExpires?: string | undefined
84
+ chainId: number
85
+ details: Record<string, string>
86
+ expectedFeeToken?: TempoAddress.Address | undefined
87
+ now?: Date | undefined
88
+ transaction: ReturnType<(typeof Transaction)['deserialize']>
89
+ }) {
90
+ const {
91
+ account,
92
+ challengeExpires,
93
+ chainId,
94
+ details,
95
+ expectedFeeToken,
96
+ now = new Date(),
97
+ transaction,
98
+ } = parameters
99
+
100
+ const {
101
+ accessList,
102
+ calls,
103
+ chainId: transactionChainId,
104
+ feeToken,
105
+ from,
106
+ gas,
107
+ maxFeePerGas,
108
+ maxPriorityFeePerGas,
109
+ nonce,
110
+ nonceKey,
111
+ signature,
112
+ validAfter,
113
+ validBefore,
114
+ } = transaction
115
+
116
+ const fail = (reason: string, extra: Record<string, string> = {}) => {
117
+ throw new FeePayerValidationError(reason, { ...details, ...extra })
118
+ }
119
+
120
+ if (transactionChainId !== chainId)
121
+ fail('fee-sponsored transaction chainId does not match challenge', {
122
+ chainId: String(transactionChainId),
123
+ })
124
+
125
+ if (gas === undefined || gas <= 0n) fail('fee-sponsored transaction must declare gas')
126
+ if (gas > policy.maxGas)
127
+ fail('fee-sponsored transaction gas exceeds sponsor policy', {
128
+ gas: gas.toString(),
129
+ })
130
+
131
+ if (maxFeePerGas === undefined || maxFeePerGas <= 0n)
132
+ fail('fee-sponsored transaction must declare maxFeePerGas')
133
+ if (maxFeePerGas > policy.maxFeePerGas)
134
+ fail('fee-sponsored transaction maxFeePerGas exceeds sponsor policy', {
135
+ maxFeePerGas: maxFeePerGas.toString(),
136
+ })
137
+
138
+ const maxTotalFee = gas * maxFeePerGas
139
+ if (maxTotalFee > policy.maxTotalFee)
140
+ fail('fee-sponsored transaction total fee budget exceeds sponsor policy', {
141
+ gas: gas.toString(),
142
+ maxFeePerGas: maxFeePerGas.toString(),
143
+ totalFee: maxTotalFee.toString(),
144
+ })
145
+
146
+ if (maxPriorityFeePerGas !== undefined && maxPriorityFeePerGas > maxFeePerGas)
147
+ fail('fee-sponsored transaction maxPriorityFeePerGas exceeds maxFeePerGas', {
148
+ maxFeePerGas: maxFeePerGas.toString(),
149
+ maxPriorityFeePerGas: maxPriorityFeePerGas.toString(),
150
+ })
151
+ if (maxPriorityFeePerGas !== undefined && maxPriorityFeePerGas > policy.maxPriorityFeePerGas)
152
+ fail('fee-sponsored transaction maxPriorityFeePerGas exceeds sponsor policy', {
153
+ maxPriorityFeePerGas: maxPriorityFeePerGas.toString(),
154
+ })
155
+
156
+ if (nonceKey === undefined) fail('fee-sponsored transaction must use an expiring nonce')
157
+ if (validBefore === undefined)
158
+ fail('fee-sponsored transaction must declare validBefore for the expiring nonce')
159
+
160
+ const nowSeconds = Math.floor(now.getTime() / 1_000)
161
+ if (validBefore <= nowSeconds)
162
+ fail('fee-sponsored transaction has already expired', {
163
+ validBefore: String(validBefore),
164
+ })
165
+
166
+ const challengeExpirySeconds = challengeExpires
167
+ ? Math.floor(new Date(challengeExpires).getTime() / 1_000)
168
+ : undefined
169
+ const maxValidBefore = Math.min(
170
+ nowSeconds + policy.maxValidityWindowSeconds,
171
+ challengeExpirySeconds ? challengeExpirySeconds + 60 : Number.MAX_SAFE_INTEGER,
172
+ )
173
+ if (validBefore > maxValidBefore)
174
+ fail('fee-sponsored transaction validity window exceeds sponsor policy', {
175
+ validBefore: String(validBefore),
176
+ })
177
+
178
+ if (feeToken !== undefined) {
179
+ if (typeof feeToken !== 'string') fail('fee-sponsored transaction feeToken is invalid')
180
+ if (expectedFeeToken && !TempoAddress_internal.isEqual(feeToken, expectedFeeToken))
181
+ fail('fee-sponsored transaction feeToken is not allowed', {
182
+ feeToken,
183
+ })
184
+ }
185
+
186
+ return {
187
+ accessList,
188
+ account,
189
+ calls,
190
+ chainId: transactionChainId,
191
+ feePayer: account,
192
+ ...(feeToken ? { feeToken } : {}),
193
+ ...(from ? { from } : {}),
194
+ gas,
195
+ ...(nonce !== undefined ? { nonce } : {}),
196
+ maxFeePerGas,
197
+ ...(maxPriorityFeePerGas !== undefined ? { maxPriorityFeePerGas } : {}),
198
+ nonceKey,
199
+ ...(signature ? { signature } : {}),
200
+ type: 'tempo' as const,
201
+ ...(validAfter !== undefined ? { validAfter } : {}),
202
+ validBefore,
203
+ } satisfies ReturnType<(typeof Transaction)['deserialize']> & {
204
+ account: Account
205
+ feePayer: Account
206
+ }
207
+ }
208
+
72
209
  export class FeePayerValidationError extends Error {
73
210
  override readonly name = 'FeePayerValidationError'
74
211
 
@@ -0,0 +1,34 @@
1
+ import { expectTypeOf, test } from 'vp/test'
2
+
3
+ import { tempo } from '../../server/index.js'
4
+ import * as Store from '../../Store.js'
5
+
6
+ test('tempo.charge store parameter requires AtomicStore', () => {
7
+ type ChargeParameters = NonNullable<Parameters<typeof tempo.charge>[0]>
8
+ expectTypeOf<ChargeParameters['store']>().toEqualTypeOf<Store.AtomicStore | undefined>()
9
+
10
+ const nonAtomic = Store.from({
11
+ get: async () => null,
12
+ put: async () => {},
13
+ delete: async () => {},
14
+ })
15
+
16
+ // @ts-expect-error — charge replay protection requires AtomicStore
17
+ tempo.charge({ store: nonAtomic })
18
+ tempo.charge({ store: Store.memory() })
19
+ })
20
+
21
+ test('tempo.session store parameter requires AtomicStore', () => {
22
+ type SessionParameters = NonNullable<Parameters<typeof tempo.session>[0]>
23
+ expectTypeOf<SessionParameters['store']>().toEqualTypeOf<Store.AtomicStore | undefined>()
24
+
25
+ const nonAtomic = Store.from({
26
+ get: async () => null,
27
+ put: async () => {},
28
+ delete: async () => {},
29
+ })
30
+
31
+ // @ts-expect-error — session state updates require AtomicStore
32
+ tempo.session({ store: nonAtomic })
33
+ tempo.session({ store: Store.memory() })
34
+ })
@@ -1019,6 +1019,76 @@ describe('tempo', () => {
1019
1019
  httpServer.close()
1020
1020
  })
1021
1021
 
1022
+ test('behavior: rejects concurrent replay of the same transaction hash', async () => {
1023
+ const dedupServer = Mppx_server.create({
1024
+ methods: [
1025
+ tempo_server.charge({
1026
+ getClient() {
1027
+ return client
1028
+ },
1029
+ currency: asset,
1030
+ account: accounts[0],
1031
+ store: Store.memory(),
1032
+ }),
1033
+ ],
1034
+ realm,
1035
+ secretKey,
1036
+ })
1037
+
1038
+ const httpServer = await Http.createServer(async (req, res) => {
1039
+ const result = await Mppx_server.toNodeListener(dedupServer.charge({ amount: '1' }))(
1040
+ req,
1041
+ res,
1042
+ )
1043
+ if (result.status === 402) return
1044
+ res.end('OK')
1045
+ })
1046
+
1047
+ const [challengeResponse1, challengeResponse2] = await Promise.all([
1048
+ fetch(httpServer.url),
1049
+ fetch(httpServer.url),
1050
+ ])
1051
+ expect(challengeResponse1.status).toBe(402)
1052
+ expect(challengeResponse2.status).toBe(402)
1053
+
1054
+ const challenge1 = Challenge.fromResponse(challengeResponse1, {
1055
+ methods: [tempo_client.charge()],
1056
+ })
1057
+ const challenge2 = Challenge.fromResponse(challengeResponse2, {
1058
+ methods: [tempo_client.charge()],
1059
+ })
1060
+
1061
+ const { receipt } = await Actions.token.transferSync(client, {
1062
+ account: accounts[1],
1063
+ amount: BigInt(challenge1.request.amount),
1064
+ memo: Attribution.encode({ challengeId: challenge1.id, serverId: realm }) as Hex.Hex,
1065
+ to: challenge1.request.recipient as Hex.Hex,
1066
+ token: challenge1.request.currency as Hex.Hex,
1067
+ })
1068
+
1069
+ const credential1 = Credential.serialize(
1070
+ Credential.from({
1071
+ challenge: challenge1,
1072
+ payload: { hash: receipt.transactionHash, type: 'hash' as const },
1073
+ }),
1074
+ )
1075
+ const credential2 = Credential.serialize(
1076
+ Credential.from({
1077
+ challenge: challenge2,
1078
+ payload: { hash: receipt.transactionHash, type: 'hash' as const },
1079
+ }),
1080
+ )
1081
+
1082
+ const [resA, resB] = await Promise.all([
1083
+ fetch(httpServer.url, { headers: { Authorization: credential1 } }),
1084
+ fetch(httpServer.url, { headers: { Authorization: credential2 } }),
1085
+ ])
1086
+
1087
+ expect([resA.status, resB.status].sort()).toEqual([200, 402])
1088
+
1089
+ httpServer.close()
1090
+ })
1091
+
1022
1092
  test('behavior: rejects malleable variants with different feePayerSignature', async () => {
1023
1093
  const dedupStore = Store.memory()
1024
1094
  const dedupServer = Mppx_server.create({
@@ -2105,6 +2175,64 @@ describe('tempo', () => {
2105
2175
  httpServer.close()
2106
2176
  })
2107
2177
 
2178
+ test('behavior: rejects concurrent replay of the same proof credential', async () => {
2179
+ const replayStore = Store.memory()
2180
+ const server_ = Mppx_server.create({
2181
+ methods: [
2182
+ tempo_server.charge({
2183
+ getClient() {
2184
+ return client
2185
+ },
2186
+ currency: asset,
2187
+ account: accounts[0],
2188
+ store: replayStore,
2189
+ }),
2190
+ ],
2191
+ realm,
2192
+ secretKey,
2193
+ })
2194
+
2195
+ const httpServer = await Http.createServer(async (req, res) => {
2196
+ const result = await Mppx_server.toNodeListener(
2197
+ server_.charge({ amount: '0', decimals: 6 }),
2198
+ )(req, res)
2199
+ if (result.status === 402) return
2200
+ res.end('OK')
2201
+ })
2202
+
2203
+ const response = await fetch(httpServer.url)
2204
+ expect(response.status).toBe(402)
2205
+
2206
+ const challenge = Challenge.fromResponse(response, {
2207
+ methods: [tempo_client.charge()],
2208
+ })
2209
+
2210
+ const signature = await signTypedData(client, {
2211
+ account: accounts[1],
2212
+ domain: Proof.domain(chain.id),
2213
+ types: Proof.types,
2214
+ primaryType: 'Proof',
2215
+ message: Proof.message(challenge.id),
2216
+ })
2217
+
2218
+ const credential = Credential.serialize(
2219
+ Credential.from({
2220
+ challenge,
2221
+ payload: { signature, type: 'proof' as const },
2222
+ source: `did:pkh:eip155:${chain.id}:${accounts[1].address}`,
2223
+ }),
2224
+ )
2225
+
2226
+ const [resA, resB] = await Promise.all([
2227
+ fetch(httpServer.url, { headers: { Authorization: credential } }),
2228
+ fetch(httpServer.url, { headers: { Authorization: credential } }),
2229
+ ])
2230
+
2231
+ expect([resA.status, resB.status].sort()).toEqual([200, 402])
2232
+
2233
+ httpServer.close()
2234
+ })
2235
+
2108
2236
  test('behavior: shared store rejects proof replay across server instances', async () => {
2109
2237
  const replayStore = Store.memory()
2110
2238
  const serverA = Mppx_server.create({