mppx 0.5.7 → 0.5.8
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.
- package/CHANGELOG.md +8 -0
- package/dist/Challenge.d.ts +3 -2
- package/dist/Challenge.d.ts.map +1 -1
- package/dist/Challenge.js +27 -9
- package/dist/Challenge.js.map +1 -1
- package/dist/Method.d.ts +32 -14
- package/dist/Method.d.ts.map +1 -1
- package/dist/Method.js.map +1 -1
- package/dist/Store.d.ts +68 -2
- package/dist/Store.d.ts.map +1 -1
- package/dist/Store.js +41 -4
- package/dist/Store.js.map +1 -1
- package/dist/mcp-sdk/server/Transport.d.ts.map +1 -1
- package/dist/mcp-sdk/server/Transport.js +7 -0
- package/dist/mcp-sdk/server/Transport.js.map +1 -1
- package/dist/server/Mppx.d.ts +1 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +133 -70
- package/dist/server/Mppx.js.map +1 -1
- package/dist/server/Transport.d.ts +8 -2
- package/dist/server/Transport.d.ts.map +1 -1
- package/dist/server/Transport.js +26 -1
- package/dist/server/Transport.js.map +1 -1
- package/dist/tempo/client/SessionManager.d.ts +13 -2
- package/dist/tempo/client/SessionManager.d.ts.map +1 -1
- package/dist/tempo/client/SessionManager.js +429 -4
- package/dist/tempo/client/SessionManager.js.map +1 -1
- package/dist/tempo/internal/fee-payer.d.ts +28 -0
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +89 -0
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts +4 -1
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +90 -66
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Methods.d.ts +3 -0
- package/dist/tempo/server/Methods.d.ts.map +1 -1
- package/dist/tempo/server/Methods.js +3 -0
- package/dist/tempo/server/Methods.js.map +1 -1
- package/dist/tempo/server/Session.d.ts +8 -2
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js.map +1 -1
- package/dist/tempo/server/index.d.ts +1 -0
- package/dist/tempo/server/index.d.ts.map +1 -1
- package/dist/tempo/server/index.js +1 -0
- package/dist/tempo/server/index.js.map +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/server/internal/transport.d.ts.map +1 -1
- package/dist/tempo/server/internal/transport.js +16 -6
- package/dist/tempo/server/internal/transport.js.map +1 -1
- package/dist/tempo/session/ChannelStore.d.ts +12 -1
- package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
- package/dist/tempo/session/ChannelStore.js +55 -14
- package/dist/tempo/session/ChannelStore.js.map +1 -1
- package/dist/tempo/session/Sse.d.ts +11 -2
- package/dist/tempo/session/Sse.d.ts.map +1 -1
- package/dist/tempo/session/Sse.js +66 -25
- package/dist/tempo/session/Sse.js.map +1 -1
- package/dist/tempo/session/Ws.d.ts +87 -0
- package/dist/tempo/session/Ws.d.ts.map +1 -0
- package/dist/tempo/session/Ws.js +428 -0
- package/dist/tempo/session/Ws.js.map +1 -0
- package/dist/tempo/session/index.d.ts +1 -0
- package/dist/tempo/session/index.d.ts.map +1 -1
- package/dist/tempo/session/index.js +1 -0
- package/dist/tempo/session/index.js.map +1 -1
- package/package.json +1 -1
- package/src/Challenge.test.ts +1 -1
- package/src/Challenge.ts +28 -9
- package/src/Method.ts +61 -20
- package/src/Store.test-d.ts +80 -2
- package/src/Store.test.ts +150 -13
- package/src/Store.ts +140 -3
- package/src/mcp-sdk/server/Transport.test.ts +12 -0
- package/src/mcp-sdk/server/Transport.ts +8 -0
- package/src/server/Mppx.test.ts +105 -0
- package/src/server/Mppx.ts +178 -88
- package/src/server/Transport.test.ts +31 -0
- package/src/server/Transport.ts +31 -2
- package/src/tempo/client/SessionManager.ts +510 -7
- package/src/tempo/internal/fee-payer.test.ts +115 -1
- package/src/tempo/internal/fee-payer.ts +138 -1
- package/src/tempo/server/AtomicStore.test-d.ts +34 -0
- package/src/tempo/server/Charge.test.ts +128 -0
- package/src/tempo/server/Charge.ts +118 -93
- package/src/tempo/server/Methods.ts +3 -0
- package/src/tempo/server/Session.test.ts +1044 -47
- package/src/tempo/server/Session.ts +8 -2
- package/src/tempo/server/Sse.test.ts +29 -0
- package/src/tempo/server/index.ts +1 -0
- package/src/tempo/server/internal/html/main.ts +9 -10
- package/src/tempo/server/internal/html.gen.ts +1 -1
- package/src/tempo/server/internal/transport.ts +19 -6
- package/src/tempo/session/ChannelStore.test.ts +20 -1
- package/src/tempo/session/ChannelStore.ts +77 -14
- package/src/tempo/session/Sse.ts +77 -24
- package/src/tempo/session/Ws.test.ts +410 -0
- package/src/tempo/session/Ws.ts +563 -0
- 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 {
|
|
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({
|