mppx 0.5.4 → 0.5.6
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 +14 -0
- package/dist/Html.d.ts +10 -0
- package/dist/Html.d.ts.map +1 -0
- package/dist/Html.js +41 -0
- package/dist/Html.js.map +1 -0
- package/dist/server/Mppx.d.ts +1 -28
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +101 -30
- package/dist/server/Mppx.js.map +1 -1
- package/dist/server/Transport.d.ts.map +1 -1
- package/dist/server/Transport.js +18 -46
- package/dist/server/Transport.js.map +1 -1
- package/dist/server/internal/html/compose.main.gen.d.ts +2 -0
- package/dist/server/internal/html/compose.main.gen.d.ts.map +1 -0
- package/dist/server/internal/html/compose.main.gen.js +3 -0
- package/dist/server/internal/html/compose.main.gen.js.map +1 -0
- package/dist/server/internal/html/config.d.ts +46 -49
- package/dist/server/internal/html/config.d.ts.map +1 -1
- package/dist/server/internal/html/config.js +323 -117
- package/dist/server/internal/html/config.js.map +1 -1
- package/dist/server/internal/html/constants.d.ts +26 -0
- package/dist/server/internal/html/constants.d.ts.map +1 -0
- package/dist/server/internal/html/constants.js +26 -0
- package/dist/server/internal/html/constants.js.map +1 -0
- package/dist/server/internal/html/serviceWorker.client.d.ts +2 -0
- package/dist/server/internal/html/serviceWorker.client.d.ts.map +1 -0
- package/dist/server/internal/html/serviceWorker.client.js +26 -0
- package/dist/server/internal/html/serviceWorker.client.js.map +1 -0
- package/dist/stripe/server/internal/html.gen.d.ts +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
- package/dist/stripe/server/internal/html.gen.js +1 -1
- package/dist/stripe/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/Attribution.d.ts +24 -7
- package/dist/tempo/Attribution.d.ts.map +1 -1
- package/dist/tempo/Attribution.js +33 -7
- package/dist/tempo/Attribution.js.map +1 -1
- package/dist/tempo/client/Charge.js +1 -1
- package/dist/tempo/client/Charge.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +36 -2
- package/dist/tempo/server/Charge.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/package.json +6 -1
- package/src/Html.ts +57 -0
- package/src/server/Mppx.test.ts +203 -0
- package/src/server/Mppx.ts +118 -3
- package/src/server/Transport.test.ts +5 -2
- package/src/server/Transport.ts +21 -54
- package/src/server/internal/html/compose.main.gen.ts +2 -0
- package/src/server/internal/html/compose.main.ts +88 -0
- package/src/server/internal/html/config.ts +422 -177
- package/src/server/internal/html/constants.ts +28 -0
- package/src/server/internal/html/serviceWorker.client.ts +2 -2
- package/src/server/internal/html/tsconfig.compose.json +8 -0
- package/src/stripe/server/internal/html/main.ts +44 -53
- package/src/stripe/server/internal/html.gen.ts +1 -1
- package/src/tempo/Attribution.test.ts +129 -23
- package/src/tempo/Attribution.ts +39 -10
- package/src/tempo/client/Charge.ts +1 -1
- package/src/tempo/server/Charge.test.ts +205 -5
- package/src/tempo/server/Charge.ts +54 -3
- package/src/tempo/server/internal/html/main.ts +26 -28
- package/src/tempo/server/internal/html.gen.ts +1 -1
|
@@ -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({
|
|
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({
|
|
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('
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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
|
|
166
|
-
const a = Attribution.decode(
|
|
167
|
-
|
|
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({
|
|
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({
|
|
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
|
})
|
package/src/tempo/Attribution.ts
CHANGED
|
@@ -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 (
|
|
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({
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
/**
|
|
74
|
-
|
|
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({
|
|
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
|
|
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({
|
|
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({
|
|
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
|
|
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(
|
|
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()
|