mppx 0.5.4 → 0.5.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.
@@ -1 +1 @@
1
- {"version":3,"file":"html.gen.js","sourceRoot":"","sources":["../../../../src/tempo/server/internal/html.gen.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,MAAM,CAAC,MAAM,IAAI,GAAG,2r9aAA2r9a,CAAA"}
1
+ {"version":3,"file":"html.gen.js","sourceRoot":"","sources":["../../../../src/tempo/server/internal/html.gen.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,MAAM,CAAC,MAAM,IAAI,GAAG,ou9aAAou9a,CAAA"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mppx",
3
3
  "type": "module",
4
- "version": "0.5.4",
4
+ "version": "0.5.5",
5
5
  "main": "./dist/index.js",
6
6
  "license": "MIT",
7
7
  "files": [
@@ -16,27 +16,38 @@ describe('Attribution', () => {
16
16
 
17
17
  describe('encode', () => {
18
18
  test('returns a 32-byte hex string', () => {
19
- const memo = Attribution.encode({ serverId: 'api.example.com' })
19
+ const memo = Attribution.encode({
20
+ challengeId: 'test-challenge-1',
21
+ serverId: 'api.example.com',
22
+ })
20
23
  // 0x prefix + 64 hex chars = 32 bytes
21
24
  expect(memo).toMatch(/^0x[0-9a-f]{64}$/i)
22
25
  })
23
26
 
24
27
  test('starts with TAG + version byte', () => {
25
- const memo = Attribution.encode({ serverId: 'api.example.com' })
28
+ const memo = Attribution.encode({
29
+ challengeId: 'test-challenge-1',
30
+ serverId: 'api.example.com',
31
+ })
26
32
  const tag = memo.slice(0, 10) // 0x + 8 hex chars
27
33
  expect(tag.toLowerCase()).toBe(Attribution.tag.toLowerCase())
28
34
  const version = memo.slice(10, 12)
29
35
  expect(version).toBe('01')
30
36
  })
31
37
 
32
- test('generates unique memos (random nonce)', () => {
33
- const a = Attribution.encode({ serverId: 'api.example.com' })
34
- const b = Attribution.encode({ serverId: 'api.example.com' })
38
+ test('produces deterministic memos (challenge-bound nonce)', () => {
39
+ const a = Attribution.encode({ challengeId: 'challenge-a', serverId: 'api.example.com' })
40
+ const b = Attribution.encode({ challengeId: 'challenge-b', serverId: 'api.example.com' })
35
41
  expect(a).not.toBe(b)
42
+ const c = Attribution.encode({ challengeId: 'challenge-a', serverId: 'api.example.com' })
43
+ expect(a).toBe(c)
36
44
  })
37
45
 
38
46
  test('encodes server fingerprint from serverId', () => {
39
- const memo = Attribution.encode({ serverId: 'api.example.com' })
47
+ const memo = Attribution.encode({
48
+ challengeId: 'test-challenge-1',
49
+ serverId: 'api.example.com',
50
+ })
40
51
  const expectedFingerprint = Hex.slice(
41
52
  Hash.keccak256(Bytes.fromString('api.example.com'), { as: 'Hex' }),
42
53
  0,
@@ -47,7 +58,11 @@ describe('Attribution', () => {
47
58
  })
48
59
 
49
60
  test('encodes client fingerprint from clientId', () => {
50
- const memo = Attribution.encode({ serverId: 'api.example.com', clientId: 'my-app' })
61
+ const memo = Attribution.encode({
62
+ challengeId: 'test-challenge-1',
63
+ clientId: 'my-app',
64
+ serverId: 'api.example.com',
65
+ })
51
66
  const expectedFingerprint = Hex.slice(
52
67
  Hash.keccak256(Bytes.fromString('my-app'), { as: 'Hex' }),
53
68
  0,
@@ -58,13 +73,20 @@ describe('Attribution', () => {
58
73
  })
59
74
 
60
75
  test('encodes zero client bytes when no clientId', () => {
61
- const memo = Attribution.encode({ serverId: 'api.example.com' })
76
+ const memo = Attribution.encode({
77
+ challengeId: 'test-challenge-1',
78
+ serverId: 'api.example.com',
79
+ })
62
80
  const clientHex = `0x${memo.slice(32, 52)}` as `0x${string}`
63
81
  expect(clientHex).toBe(Attribution.anonymous)
64
82
  })
65
83
 
66
84
  test('treats empty string clientId as anonymous', () => {
67
- const memo = Attribution.encode({ serverId: 'api.example.com', clientId: '' })
85
+ const memo = Attribution.encode({
86
+ challengeId: 'test-challenge-1',
87
+ clientId: '',
88
+ serverId: 'api.example.com',
89
+ })
68
90
  const clientHex = `0x${memo.slice(32, 52)}` as `0x${string}`
69
91
  expect(clientHex).toBe(Attribution.anonymous)
70
92
  const decoded = Attribution.decode(memo)
@@ -75,12 +97,19 @@ describe('Attribution', () => {
75
97
 
76
98
  describe('isMppMemo', () => {
77
99
  test('returns true for encoded memos', () => {
78
- const memo = Attribution.encode({ serverId: 'api.example.com' })
100
+ const memo = Attribution.encode({
101
+ challengeId: 'test-challenge-1',
102
+ serverId: 'api.example.com',
103
+ })
79
104
  expect(Attribution.isMppMemo(memo)).toBe(true)
80
105
  })
81
106
 
82
107
  test('returns true for encoded memos with clientId', () => {
83
- const memo = Attribution.encode({ serverId: 'api.example.com', clientId: 'my-app' })
108
+ const memo = Attribution.encode({
109
+ challengeId: 'test-challenge-1',
110
+ clientId: 'my-app',
111
+ serverId: 'api.example.com',
112
+ })
84
113
  expect(Attribution.isMppMemo(memo)).toBe(true)
85
114
  })
86
115
 
@@ -101,13 +130,19 @@ describe('Attribution', () => {
101
130
  })
102
131
 
103
132
  test('returns false for wrong version', () => {
104
- const memo = Attribution.encode({ serverId: 'api.example.com' })
133
+ const memo = Attribution.encode({
134
+ challengeId: 'test-challenge-1',
135
+ serverId: 'api.example.com',
136
+ })
105
137
  const wrongVersion = `${memo.slice(0, 10)}ff${memo.slice(12)}` as `0x${string}`
106
138
  expect(Attribution.isMppMemo(wrongVersion)).toBe(false)
107
139
  })
108
140
 
109
141
  test('handles mixed case hex', () => {
110
- const memo = Attribution.encode({ serverId: 'api.example.com' })
142
+ const memo = Attribution.encode({
143
+ challengeId: 'test-challenge-1',
144
+ serverId: 'api.example.com',
145
+ })
111
146
  const tagUpper = memo.slice(0, 10).toUpperCase()
112
147
  const mixed = `0x${tagUpper.slice(2)}${memo.slice(10)}` as `0x${string}`
113
148
  expect(Attribution.isMppMemo(mixed)).toBe(true)
@@ -116,17 +151,27 @@ describe('Attribution', () => {
116
151
 
117
152
  describe('verifyServer', () => {
118
153
  test('returns true for matching serverId', () => {
119
- const memo = Attribution.encode({ serverId: 'api.example.com' })
154
+ const memo = Attribution.encode({
155
+ challengeId: 'test-challenge-1',
156
+ serverId: 'api.example.com',
157
+ })
120
158
  expect(Attribution.verifyServer(memo, 'api.example.com')).toBe(true)
121
159
  })
122
160
 
123
161
  test('returns true for matching serverId with clientId', () => {
124
- const memo = Attribution.encode({ serverId: 'api.example.com', clientId: 'my-app' })
162
+ const memo = Attribution.encode({
163
+ challengeId: 'test-challenge-1',
164
+ clientId: 'my-app',
165
+ serverId: 'api.example.com',
166
+ })
125
167
  expect(Attribution.verifyServer(memo, 'api.example.com')).toBe(true)
126
168
  })
127
169
 
128
170
  test('returns false for wrong serverId', () => {
129
- const memo = Attribution.encode({ serverId: 'api.example.com' })
171
+ const memo = Attribution.encode({
172
+ challengeId: 'test-challenge-1',
173
+ serverId: 'api.example.com',
174
+ })
130
175
  expect(Attribution.verifyServer(memo, 'other.example.com')).toBe(false)
131
176
  })
132
177
 
@@ -139,7 +184,11 @@ describe('Attribution', () => {
139
184
 
140
185
  describe('decode', () => {
141
186
  test('decodes an encoded memo with serverId and clientId', () => {
142
- const memo = Attribution.encode({ serverId: 'api.example.com', clientId: 'my-app' })
187
+ const memo = Attribution.encode({
188
+ challengeId: 'test-challenge-1',
189
+ clientId: 'my-app',
190
+ serverId: 'api.example.com',
191
+ })
143
192
  const result = Attribution.decode(memo)
144
193
  expect(result).not.toBeNull()
145
194
  expect(result!.version).toBe(1)
@@ -150,7 +199,10 @@ describe('Attribution', () => {
150
199
  })
151
200
 
152
201
  test('decodes anonymous client as null', () => {
153
- const memo = Attribution.encode({ serverId: 'api.example.com' })
202
+ const memo = Attribution.encode({
203
+ challengeId: 'test-challenge-1',
204
+ serverId: 'api.example.com',
205
+ })
154
206
  const result = Attribution.decode(memo)
155
207
  expect(result).not.toBeNull()
156
208
  expect(result!.clientFingerprint).toBeNull()
@@ -162,14 +214,22 @@ describe('Attribution', () => {
162
214
  expect(Attribution.decode(arbitrary)).toBeNull()
163
215
  })
164
216
 
165
- test('different encodes produce different nonces', () => {
166
- const a = Attribution.decode(Attribution.encode({ serverId: 'api.example.com' }))
167
- const b = Attribution.decode(Attribution.encode({ serverId: 'api.example.com' }))
217
+ test('different challengeIds produce different nonces', () => {
218
+ const a = Attribution.decode(
219
+ Attribution.encode({ challengeId: 'challenge-a', serverId: 'api.example.com' }),
220
+ )
221
+ const b = Attribution.decode(
222
+ Attribution.encode({ challengeId: 'challenge-b', serverId: 'api.example.com' }),
223
+ )
168
224
  expect(a!.nonce).not.toBe(b!.nonce)
169
225
  })
170
226
 
171
227
  test('serverId fingerprint matches expected keccak hash', () => {
172
- const memo = Attribution.encode({ serverId: 'api.example.com', clientId: 'my-app' })
228
+ const memo = Attribution.encode({
229
+ challengeId: 'test-challenge-1',
230
+ clientId: 'my-app',
231
+ serverId: 'api.example.com',
232
+ })
173
233
  const result = Attribution.decode(memo)!
174
234
  const expectedServer = Hex.slice(
175
235
  Hash.keccak256(Bytes.fromString('api.example.com'), { as: 'Hex' }),
@@ -180,9 +240,55 @@ describe('Attribution', () => {
180
240
  })
181
241
 
182
242
  test('returns null for wrong version via decode', () => {
183
- const memo = Attribution.encode({ serverId: 'api.example.com' })
243
+ const memo = Attribution.encode({
244
+ challengeId: 'test-challenge-1',
245
+ serverId: 'api.example.com',
246
+ })
184
247
  const corrupted = `${memo.slice(0, 10)}ff${memo.slice(12)}` as `0x${string}`
185
248
  expect(Attribution.decode(corrupted)).toBeNull()
186
249
  })
187
250
  })
251
+
252
+ describe('challengeNonce', () => {
253
+ test('returns 7 bytes', () => {
254
+ const nonce = Attribution.challengeNonce('challenge-123')
255
+ expect(nonce.length).toBe(7)
256
+ })
257
+
258
+ test('is deterministic', () => {
259
+ const a = Attribution.challengeNonce('challenge-123')
260
+ const b = Attribution.challengeNonce('challenge-123')
261
+ expect(Hex.fromBytes(a)).toBe(Hex.fromBytes(b))
262
+ })
263
+
264
+ test('differs for different challengeIds', () => {
265
+ const a = Attribution.challengeNonce('challenge-123')
266
+ const b = Attribution.challengeNonce('challenge-456')
267
+ expect(Hex.fromBytes(a)).not.toBe(Hex.fromBytes(b))
268
+ })
269
+ })
270
+
271
+ describe('verifyChallengeBinding', () => {
272
+ test('returns true for matching challengeId', () => {
273
+ const memo = Attribution.encode({
274
+ challengeId: 'challenge-123',
275
+ serverId: 'api.example.com',
276
+ })
277
+ expect(Attribution.verifyChallengeBinding(memo, 'challenge-123')).toBe(true)
278
+ })
279
+
280
+ test('returns false for wrong challengeId', () => {
281
+ const memo = Attribution.encode({
282
+ challengeId: 'challenge-123',
283
+ serverId: 'api.example.com',
284
+ })
285
+ expect(Attribution.verifyChallengeBinding(memo, 'challenge-456')).toBe(false)
286
+ })
287
+
288
+ test('returns false for non-MPP memo', () => {
289
+ const arbitrary =
290
+ '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' as `0x${string}`
291
+ expect(Attribution.verifyChallengeBinding(arbitrary, 'challenge-123')).toBe(false)
292
+ })
293
+ })
188
294
  })
@@ -14,7 +14,7 @@ import { Bytes, Hash, Hex } from 'ox'
14
14
  * | 4 | 1 | version (0x01) |
15
15
  * | 5..14 | 10 | serverId = keccak256(serverId)[0..9] |
16
16
  * | 15..24 | 10 | clientId = keccak256(clientId)[0..9] or 0s |
17
- * | 25..31 | 7 | nonce (random bytes) |
17
+ * | 25..31 | 7 | nonce = keccak256(challengeId)[0..6] |
18
18
  *
19
19
  * The TAG prefix makes MPP transactions trivially distinguishable
20
20
  * from arbitrary memos via `TransferWithMemo` event topic filtering.
@@ -50,11 +50,11 @@ function fingerprint(value: string): Uint8Array {
50
50
  * ```ts
51
51
  * import * as Attribution from './Attribution.js'
52
52
  *
53
- * const memo = Attribution.encode({ serverId: 'api.example.com', clientId: 'my-app' })
53
+ * const memo = Attribution.encode({ challengeId: 'challenge-123', clientId: 'my-app', serverId: 'api.example.com' })
54
54
  * ```
55
55
  */
56
56
  export function encode(parameters: encode.Parameters) {
57
- const { serverId, clientId } = parameters
57
+ const { serverId, clientId, challengeId } = parameters
58
58
  const buf = new Uint8Array(32)
59
59
 
60
60
  buf.set(Hex.toBytes(tag), 0)
@@ -62,18 +62,22 @@ export function encode(parameters: encode.Parameters) {
62
62
  buf.set(fingerprint(serverId), 5)
63
63
  if (clientId) buf.set(fingerprint(clientId), 15)
64
64
 
65
- const nonce = crypto.getRandomValues(new Uint8Array(7))
66
- buf.set(nonce, 25)
65
+ // Derive the nonce from keccak256(challengeId)[0..6] to bind the memo
66
+ // to the challenge and prevent transaction hash stealing.
67
+ // TODO: expand to full memo verification once the server tracks the complete attribution payload.
68
+ buf.set(challengeNonce(challengeId), 25)
67
69
 
68
70
  return Hex.fromBytes(buf)
69
71
  }
70
72
 
71
73
  export declare namespace encode {
72
74
  type Parameters = {
73
- /** Server identity used to derive the server fingerprint. */
74
- serverId: string
75
+ /** Challenge ID used to derive a deterministic nonce, binding the memo to the challenge. */
76
+ challengeId: string
75
77
  /** Optional client identity used to derive the client fingerprint. */
76
78
  clientId?: string | undefined
79
+ /** Server identity used to derive the server fingerprint. */
80
+ serverId: string
77
81
  }
78
82
  }
79
83
 
@@ -87,7 +91,7 @@ export declare namespace encode {
87
91
  * ```ts
88
92
  * import * as Attribution from './Attribution.js'
89
93
  *
90
- * Attribution.isMppMemo(Attribution.encode({ serverId: 'api.example.com' })) // true
94
+ * Attribution.isMppMemo(Attribution.encode({ challengeId: 'challenge-123', serverId: 'api.example.com' })) // true
91
95
  * Attribution.isMppMemo('0x0000...0000') // false
92
96
  * ```
93
97
  */
@@ -124,7 +128,7 @@ export function verifyServer(memo: `0x${string}`, serverId: string): boolean {
124
128
  * ```ts
125
129
  * import * as Attribution from './Attribution.js'
126
130
  *
127
- * const memo = Attribution.encode({ serverId: 'api.example.com', clientId: 'my-app' })
131
+ * const memo = Attribution.encode({ challengeId: 'challenge-123', clientId: 'my-app', serverId: 'api.example.com' })
128
132
  * const decoded = Attribution.decode(memo)
129
133
  * // { version: 1, serverFingerprint: '0x...', clientFingerprint: '0x...', nonce: '0x...' }
130
134
  * ```
@@ -150,7 +154,32 @@ export declare namespace decode {
150
154
  serverFingerprint: `0x${string}`
151
155
  /** 10-byte client fingerprint hex, or `null` if anonymous. */
152
156
  clientFingerprint: `0x${string}` | null
153
- /** 7-byte random nonce hex. */
157
+ /** 7-byte challenge-bound nonce hex (keccak256(challengeId)[0..6]). */
154
158
  nonce: `0x${string}`
155
159
  }
156
160
  }
161
+
162
+ /**
163
+ * Computes the 7-byte challenge-bound nonce: keccak256(challengeId)[0..6].
164
+ * @internal
165
+ */
166
+ export function challengeNonce(challengeId: string): Uint8Array {
167
+ const hash = Hash.keccak256(Bytes.fromString(challengeId), { as: 'Hex' })
168
+ return Hex.toBytes(Hex.slice(hash, 0, 7))
169
+ }
170
+
171
+ /**
172
+ * Verifies that a memo's nonce is bound to the given challengeId.
173
+ *
174
+ * Checks TAG, version byte, and that bytes 25–31 equal keccak256(challengeId)[0..6].
175
+ *
176
+ * @param memo - A `0x`-prefixed hex string (bytes32).
177
+ * @param challengeId - The expected challenge ID.
178
+ * @returns `true` if the memo has a valid MPP tag and matching challenge nonce.
179
+ */
180
+ export function verifyChallengeBinding(memo: `0x${string}`, challengeId: string): boolean {
181
+ const decoded = decode(memo)
182
+ if (!decoded) return false
183
+ const expectedNonce = Hex.fromBytes(challengeNonce(challengeId))
184
+ return decoded.nonce.toLowerCase() === expectedNonce.toLowerCase()
185
+ }
@@ -92,7 +92,7 @@ export function charge(parameters: charge.Parameters = {}) {
92
92
 
93
93
  const memo = methodDetails?.memo
94
94
  ? (methodDetails.memo as Hex.Hex)
95
- : Attribution.encode({ serverId: challenge.realm, clientId })
95
+ : Attribution.encode({ challengeId: challenge.id, clientId, serverId: challenge.realm })
96
96
  const transfers = Charge_internal.getTransfers({
97
97
  amount,
98
98
  methodDetails: {
@@ -154,6 +154,7 @@ describe('tempo', () => {
154
154
  const { receipt } = await Actions.token.transferSync(client, {
155
155
  account: accounts[1],
156
156
  amount: BigInt(challenge1.request.amount),
157
+ memo: Attribution.encode({ challengeId: challenge1.id, serverId: realm }) as Hex.Hex,
157
158
  to: challenge1.request.recipient as Hex.Hex,
158
159
  token: challenge1.request.currency as Hex.Hex,
159
160
  })
@@ -231,6 +232,7 @@ describe('tempo', () => {
231
232
  const { receipt } = await Actions.token.transferSync(client, {
232
233
  account: accounts[1],
233
234
  amount: BigInt(challenge1.request.amount),
235
+ memo: Attribution.encode({ challengeId: challenge1.id, serverId: realm }) as Hex.Hex,
234
236
  to: challenge1.request.recipient as Hex.Hex,
235
237
  token: challenge1.request.currency as Hex.Hex,
236
238
  })
@@ -314,6 +316,7 @@ describe('tempo', () => {
314
316
  const { receipt } = await Actions.token.transferSync(client, {
315
317
  account: accounts[1],
316
318
  amount: BigInt(challenge1.request.amount),
319
+ memo: Attribution.encode({ challengeId: challenge1.id, serverId: realm }) as Hex.Hex,
317
320
  to: challenge1.request.recipient as Hex.Hex,
318
321
  token: challenge1.request.currency as Hex.Hex,
319
322
  })
@@ -477,6 +480,7 @@ describe('tempo', () => {
477
480
  const { receipt } = await Actions.token.transferSync(client, {
478
481
  account: accounts[1],
479
482
  amount: BigInt(challenge1.request.amount),
483
+ memo: Attribution.encode({ challengeId: challenge1.id, serverId: realm }) as Hex.Hex,
480
484
  to: challenge1.request.recipient as Hex.Hex,
481
485
  token: challenge1.request.currency as Hex.Hex,
482
486
  })
@@ -554,6 +558,7 @@ describe('tempo', () => {
554
558
  const { receipt } = await Actions.token.transferSync(client, {
555
559
  account: accounts[1],
556
560
  amount: BigInt(challenge1.request.amount),
561
+ memo: Attribution.encode({ challengeId: challenge1.id, serverId: realm }) as Hex.Hex,
557
562
  to: challenge1.request.recipient as Hex.Hex,
558
563
  token: challenge1.request.currency as Hex.Hex,
559
564
  })
@@ -635,6 +640,7 @@ describe('tempo', () => {
635
640
  const { receipt } = await Actions.token.transferSync(client, {
636
641
  account: accounts[1],
637
642
  amount: BigInt(challenge.request.amount),
643
+ memo: Attribution.encode({ challengeId: challenge.id, serverId: realm }) as Hex.Hex,
638
644
  to: challenge.request.recipient as Hex.Hex,
639
645
  token: challenge.request.currency as Hex.Hex,
640
646
  })
@@ -1570,7 +1576,7 @@ describe('tempo', () => {
1570
1576
  })
1571
1577
  const request = challenge.request
1572
1578
 
1573
- const memo = Attribution.encode({ serverId: challenge.realm })
1579
+ const memo = Attribution.encode({ challengeId: challenge.id, serverId: challenge.realm })
1574
1580
 
1575
1581
  // Build a transaction with the valid transfer + a rogue extra call
1576
1582
  const transferCall = Actions.token.transfer.call({
@@ -2880,7 +2886,11 @@ describe('tempo', () => {
2880
2886
 
2881
2887
  expect(challenge.request.methodDetails?.memo).toBeUndefined()
2882
2888
 
2883
- const memo = Attribution.encode({ serverId: challenge.realm, clientId: 'test-app' })
2889
+ const memo = Attribution.encode({
2890
+ challengeId: challenge.id,
2891
+ clientId: 'test-app',
2892
+ serverId: challenge.realm,
2893
+ })
2884
2894
  expect(Attribution.isMppMemo(memo)).toBe(true)
2885
2895
  expect(Attribution.verifyServer(memo, realm)).toBe(true)
2886
2896
 
@@ -2926,7 +2936,7 @@ describe('tempo', () => {
2926
2936
  methods: [tempo_client.charge()],
2927
2937
  })
2928
2938
 
2929
- const memo = Attribution.encode({ serverId: challenge.realm })
2939
+ const memo = Attribution.encode({ challengeId: challenge.id, serverId: challenge.realm })
2930
2940
  const decoded = Attribution.decode(memo)
2931
2941
  expect(decoded).not.toBeNull()
2932
2942
  expect(decoded!.clientFingerprint).toBeNull()
@@ -2986,7 +2996,7 @@ describe('tempo', () => {
2986
2996
  httpServer.close()
2987
2997
  })
2988
2998
 
2989
- test('server accepts plain transfer without memo', async () => {
2999
+ test('server rejects plain transfer without challenge-bound memo', async () => {
2990
3000
  const httpServer = await Http.createServer(async (req, res) => {
2991
3001
  const result = await Mppx_server.toNodeListener(
2992
3002
  server.charge({ amount: '1', decimals: 6 }),
@@ -3018,7 +3028,197 @@ describe('tempo', () => {
3018
3028
  const response = await fetch(httpServer.url, {
3019
3029
  headers: { Authorization: Credential.serialize(credential) },
3020
3030
  })
3021
- expect(response.status).toBe(200)
3031
+ expect(response.status).toBe(402)
3032
+ const body = (await response.json()) as { detail: string }
3033
+ expect(body.detail).toContain('memo is not bound to this challenge')
3034
+ }
3035
+
3036
+ httpServer.close()
3037
+ })
3038
+
3039
+ test('server rejects hash with wrong challenge nonce (stolen tx)', async () => {
3040
+ const httpServer = await Http.createServer(async (req, res) => {
3041
+ const result = await Mppx_server.toNodeListener(
3042
+ server.charge({ amount: '1', decimals: 6 }),
3043
+ )(req, res)
3044
+ if (result.status === 402) return
3045
+ res.end('OK')
3046
+ })
3047
+
3048
+ // Get two different challenges
3049
+ const response1 = await fetch(httpServer.url)
3050
+ expect(response1.status).toBe(402)
3051
+ const challenge1 = Challenge.fromResponse(response1, {
3052
+ methods: [tempo_client.charge()],
3053
+ })
3054
+
3055
+ const response2 = await fetch(httpServer.url)
3056
+ expect(response2.status).toBe(402)
3057
+ const challenge2 = Challenge.fromResponse(response2, {
3058
+ methods: [tempo_client.charge()],
3059
+ })
3060
+
3061
+ // Legitimate transfer bound to challenge1
3062
+ const memo = Attribution.encode({ challengeId: challenge1.id, serverId: realm })
3063
+ const { receipt } = await Actions.token.transferSync(client, {
3064
+ account: accounts[1],
3065
+ amount: BigInt(challenge1.request.amount),
3066
+ memo: memo as Hex.Hex,
3067
+ to: challenge1.request.recipient as Hex.Hex,
3068
+ token: challenge1.request.currency as Hex.Hex,
3069
+ })
3070
+
3071
+ // Attacker tries to use this tx hash against challenge2
3072
+ const credential = Credential.from({
3073
+ challenge: challenge2,
3074
+ payload: { hash: receipt.transactionHash, type: 'hash' as const },
3075
+ })
3076
+
3077
+ {
3078
+ const response = await fetch(httpServer.url, {
3079
+ headers: { Authorization: Credential.serialize(credential) },
3080
+ })
3081
+ expect(response.status).toBe(402)
3082
+ const body = (await response.json()) as { detail: string }
3083
+ expect(body.detail).toContain('memo is not bound to this challenge')
3084
+ }
3085
+
3086
+ httpServer.close()
3087
+ })
3088
+
3089
+ test('server rejects hash with non-MPP memo', async () => {
3090
+ const httpServer = await Http.createServer(async (req, res) => {
3091
+ const result = await Mppx_server.toNodeListener(
3092
+ server.charge({ amount: '1', decimals: 6 }),
3093
+ )(req, res)
3094
+ if (result.status === 402) return
3095
+ res.end('OK')
3096
+ })
3097
+
3098
+ const response = await fetch(httpServer.url)
3099
+ expect(response.status).toBe(402)
3100
+
3101
+ const challenge = Challenge.fromResponse(response, {
3102
+ methods: [tempo_client.charge()],
3103
+ })
3104
+
3105
+ // Transfer with an arbitrary non-MPP memo
3106
+ const arbitraryMemo =
3107
+ '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Hex.Hex
3108
+ const { receipt } = await Actions.token.transferSync(client, {
3109
+ account: accounts[1],
3110
+ amount: BigInt(challenge.request.amount),
3111
+ memo: arbitraryMemo,
3112
+ to: challenge.request.recipient as Hex.Hex,
3113
+ token: challenge.request.currency as Hex.Hex,
3114
+ })
3115
+
3116
+ const credential = Credential.from({
3117
+ challenge,
3118
+ payload: { hash: receipt.transactionHash, type: 'hash' as const },
3119
+ })
3120
+
3121
+ {
3122
+ const response = await fetch(httpServer.url, {
3123
+ headers: { Authorization: Credential.serialize(credential) },
3124
+ })
3125
+ expect(response.status).toBe(402)
3126
+ const body = (await response.json()) as { detail: string }
3127
+ expect(body.detail).toContain('memo is not bound to this challenge')
3128
+ }
3129
+
3130
+ httpServer.close()
3131
+ })
3132
+
3133
+ test('server rejects hash when challenge nonce is on dust transfer, not payment', async () => {
3134
+ const dedupStore = Store.memory()
3135
+ const chargeServer = Mppx_server.create({
3136
+ methods: [
3137
+ tempo_server.charge({
3138
+ getClient() {
3139
+ return client
3140
+ },
3141
+ currency: asset,
3142
+ account: accounts[0],
3143
+ store: dedupStore,
3144
+ }),
3145
+ ],
3146
+ realm,
3147
+ secretKey,
3148
+ })
3149
+
3150
+ const httpServer = await Http.createServer(async (req, res) => {
3151
+ const result = await Mppx_server.toNodeListener(
3152
+ chargeServer.charge({ amount: '1', decimals: 6 }),
3153
+ )(req, res)
3154
+ if (result.status === 402) return
3155
+ res.end('OK')
3156
+ })
3157
+
3158
+ // Get two challenges
3159
+ const response1 = await fetch(httpServer.url)
3160
+ expect(response1.status).toBe(402)
3161
+ const challengeA = Challenge.fromResponse(response1, {
3162
+ methods: [tempo_client.charge()],
3163
+ })
3164
+
3165
+ const response2 = await fetch(httpServer.url)
3166
+ expect(response2.status).toBe(402)
3167
+ const challengeB = Challenge.fromResponse(response2, {
3168
+ methods: [tempo_client.charge()],
3169
+ })
3170
+
3171
+ // Craft a multi-call tx: primary payment with challengeA's nonce,
3172
+ // plus a same-currency dust transfer with challengeB's nonce.
3173
+ // Without matched-log binding, the dust transfer would satisfy
3174
+ // challengeB's binding check on the same tx hash.
3175
+ const memoA = Attribution.encode({ challengeId: challengeA.id, serverId: realm })
3176
+ const memoB = Attribution.encode({ challengeId: challengeB.id, serverId: realm })
3177
+
3178
+ // Broadcast a multi-call tx: payment to the merchant with challengeA's
3179
+ // nonce, plus a same-currency dust transfer with challengeB's nonce.
3180
+ const prepared = await prepareTransactionRequest(client, {
3181
+ account: accounts[1]!,
3182
+ calls: [
3183
+ Actions.token.transfer.call({
3184
+ amount: BigInt(challengeA.request.amount),
3185
+ memo: memoA as Hex.Hex,
3186
+ to: challengeA.request.recipient as Hex.Hex,
3187
+ token: challengeA.request.currency as Hex.Hex,
3188
+ }),
3189
+ // Dust transfer with challengeB's nonce — the exploit vector
3190
+ Actions.token.transfer.call({
3191
+ amount: 1n,
3192
+ memo: memoB as Hex.Hex,
3193
+ to: accounts[2].address,
3194
+ token: challengeA.request.currency as Hex.Hex,
3195
+ }),
3196
+ ],
3197
+ nonceKey: 'expiring',
3198
+ } as never)
3199
+ prepared.gas = prepared.gas! + 5_000n
3200
+ const sig = await signTransaction(client, prepared as never)
3201
+ const { transactionHash } = await (
3202
+ await import('viem/actions')
3203
+ ).sendRawTransactionSync(client, {
3204
+ serializedTransaction: sig,
3205
+ })
3206
+
3207
+ // Submit hash against challengeB — the matched payment log (to the
3208
+ // merchant for the correct amount) carries challengeA's nonce. The dust
3209
+ // transfer carries challengeB's nonce but should NOT satisfy the check
3210
+ // since it wasn't the matched payment log.
3211
+ {
3212
+ const credential = Credential.from({
3213
+ challenge: challengeB,
3214
+ payload: { hash: transactionHash, type: 'hash' as const },
3215
+ })
3216
+ const response = await fetch(httpServer.url, {
3217
+ headers: { Authorization: Credential.serialize(credential) },
3218
+ })
3219
+ expect(response.status).toBe(402)
3220
+ const body = (await response.json()) as { detail: string }
3221
+ expect(body.detail).toContain('memo is not bound to this challenge')
3022
3222
  }
3023
3223
 
3024
3224
  httpServer.close()