@luxexchange/sessions 1.0.0
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/.depcheckrc +20 -0
- package/.eslintrc.js +21 -0
- package/README.md +1 -0
- package/env.d.ts +12 -0
- package/package.json +50 -0
- package/project.json +42 -0
- package/src/challenge-solvers/createChallengeSolverService.ts +64 -0
- package/src/challenge-solvers/createHashcashMockSolver.ts +39 -0
- package/src/challenge-solvers/createHashcashSolver.test.ts +385 -0
- package/src/challenge-solvers/createHashcashSolver.ts +255 -0
- package/src/challenge-solvers/createNoneMockSolver.ts +11 -0
- package/src/challenge-solvers/createTurnstileMockSolver.ts +30 -0
- package/src/challenge-solvers/createTurnstileSolver.ts +353 -0
- package/src/challenge-solvers/hashcash/core.native.ts +34 -0
- package/src/challenge-solvers/hashcash/core.test.ts +314 -0
- package/src/challenge-solvers/hashcash/core.ts +35 -0
- package/src/challenge-solvers/hashcash/core.web.ts +123 -0
- package/src/challenge-solvers/hashcash/createWorkerHashcashSolver.test.ts +195 -0
- package/src/challenge-solvers/hashcash/createWorkerHashcashSolver.ts +120 -0
- package/src/challenge-solvers/hashcash/shared.ts +70 -0
- package/src/challenge-solvers/hashcash/worker/createHashcashMultiWorkerChannel.native.ts +22 -0
- package/src/challenge-solvers/hashcash/worker/createHashcashMultiWorkerChannel.ts +22 -0
- package/src/challenge-solvers/hashcash/worker/createHashcashMultiWorkerChannel.web.ts +212 -0
- package/src/challenge-solvers/hashcash/worker/createHashcashWorkerChannel.native.ts +16 -0
- package/src/challenge-solvers/hashcash/worker/createHashcashWorkerChannel.ts +16 -0
- package/src/challenge-solvers/hashcash/worker/createHashcashWorkerChannel.web.ts +97 -0
- package/src/challenge-solvers/hashcash/worker/hashcash.worker.ts +91 -0
- package/src/challenge-solvers/hashcash/worker/types.ts +69 -0
- package/src/challenge-solvers/turnstileErrors.ts +49 -0
- package/src/challenge-solvers/turnstileScriptLoader.ts +325 -0
- package/src/challenge-solvers/turnstileSolver.integration.test.ts +331 -0
- package/src/challenge-solvers/types.ts +55 -0
- package/src/challengeFlow.integration.test.ts +627 -0
- package/src/device-id/createDeviceIdService.ts +19 -0
- package/src/device-id/types.ts +11 -0
- package/src/index.ts +137 -0
- package/src/lux-identifier/createLuxIdentifierService.ts +19 -0
- package/src/lux-identifier/luxIdentifierQuery.ts +20 -0
- package/src/lux-identifier/types.ts +11 -0
- package/src/oauth-service/createOAuthService.ts +125 -0
- package/src/oauth-service/types.ts +104 -0
- package/src/performance/createNoopPerformanceTracker.ts +12 -0
- package/src/performance/createPerformanceTracker.ts +43 -0
- package/src/performance/index.ts +7 -0
- package/src/performance/types.ts +11 -0
- package/src/session-initialization/createSessionInitializationService.test.ts +557 -0
- package/src/session-initialization/createSessionInitializationService.ts +193 -0
- package/src/session-initialization/sessionErrors.ts +32 -0
- package/src/session-repository/createSessionClient.ts +10 -0
- package/src/session-repository/createSessionRepository.test.ts +313 -0
- package/src/session-repository/createSessionRepository.ts +242 -0
- package/src/session-repository/errors.ts +22 -0
- package/src/session-repository/types.ts +289 -0
- package/src/session-service/createNoopSessionService.ts +24 -0
- package/src/session-service/createSessionService.test.ts +388 -0
- package/src/session-service/createSessionService.ts +61 -0
- package/src/session-service/types.ts +59 -0
- package/src/session-storage/createSessionStorage.ts +28 -0
- package/src/session-storage/types.ts +15 -0
- package/src/session.integration.test.ts +480 -0
- package/src/sessionLifecycle.integration.test.ts +264 -0
- package/src/test-utils/createLocalCookieTransport.ts +52 -0
- package/src/test-utils/createLocalHeaderTransport.ts +45 -0
- package/src/test-utils/mocks.ts +122 -0
- package/src/test-utils.ts +200 -0
- package/tsconfig.json +19 -0
- package/tsconfig.lint.json +8 -0
- package/tsconfig.spec.json +8 -0
- package/vitest.config.ts +20 -0
- package/vitest.integration.config.ts +14 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import {
|
|
2
|
+
checkDifficulty,
|
|
3
|
+
computeHash,
|
|
4
|
+
findProof,
|
|
5
|
+
formatHashcashString,
|
|
6
|
+
type HashcashChallenge,
|
|
7
|
+
verifyProof,
|
|
8
|
+
} from '@luxfi/sessions/src/challenge-solvers/hashcash/core'
|
|
9
|
+
import { describe, expect, it } from 'vitest'
|
|
10
|
+
|
|
11
|
+
describe('hashcash core', () => {
|
|
12
|
+
// Backend example data for testing
|
|
13
|
+
const backendExample: HashcashChallenge = {
|
|
14
|
+
difficulty: 1,
|
|
15
|
+
subject: 'Lux',
|
|
16
|
+
algorithm: 'sha256',
|
|
17
|
+
nonce: 'Qlquffem7d8RrL6fmveE68XK0KxcoczdiVpFrV1qeUk=',
|
|
18
|
+
max_proof_length: 1000,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Basic smoke test
|
|
22
|
+
it('exports all required functions', () => {
|
|
23
|
+
expect(checkDifficulty).toBeDefined()
|
|
24
|
+
expect(computeHash).toBeDefined()
|
|
25
|
+
expect(findProof).toBeDefined()
|
|
26
|
+
expect(verifyProof).toBeDefined()
|
|
27
|
+
expect(formatHashcashString).toBeDefined()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('checkDifficulty', () => {
|
|
31
|
+
it('validates difficulty 1 (1 full zero byte)', () => {
|
|
32
|
+
// Backend treats difficulty as number of zero BYTES
|
|
33
|
+
// difficulty=1 means first byte must be 0
|
|
34
|
+
const validHash = new Uint8Array([0, 255, 255, 255])
|
|
35
|
+
expect(checkDifficulty(validHash, 1)).toBe(true)
|
|
36
|
+
|
|
37
|
+
const invalidHash = new Uint8Array([1, 255, 255, 255])
|
|
38
|
+
expect(checkDifficulty(invalidHash, 1)).toBe(false)
|
|
39
|
+
|
|
40
|
+
const invalidHash2 = new Uint8Array([0b00000001, 255, 255, 255]) // Even 1 bit set fails
|
|
41
|
+
expect(checkDifficulty(invalidHash2, 1)).toBe(false)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('validates difficulty 2 (2 full zero bytes)', () => {
|
|
45
|
+
// First two bytes must be all zeros
|
|
46
|
+
const validHash = new Uint8Array([0, 0, 255, 255])
|
|
47
|
+
expect(checkDifficulty(validHash, 2)).toBe(true)
|
|
48
|
+
|
|
49
|
+
const invalidHash = new Uint8Array([0, 1, 255, 255]) // Second byte not zero
|
|
50
|
+
expect(checkDifficulty(invalidHash, 2)).toBe(false)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('validates difficulty 0 (no requirement)', () => {
|
|
54
|
+
const anyHash = new Uint8Array([255, 255, 255, 255])
|
|
55
|
+
expect(checkDifficulty(anyHash, 0)).toBe(true)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('computeHash', () => {
|
|
60
|
+
it('produces consistent SHA-256 hashes', async () => {
|
|
61
|
+
const params = {
|
|
62
|
+
subject: 'test',
|
|
63
|
+
nonce: 'AQIDBA==', // Base64 string
|
|
64
|
+
counter: 42,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const hash1 = await computeHash(params)
|
|
68
|
+
const hash2 = await computeHash(params)
|
|
69
|
+
|
|
70
|
+
expect(hash1).toEqual(hash2)
|
|
71
|
+
expect(hash1.length).toBe(32) // SHA-256 is 32 bytes
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('works with real backend nonce', async () => {
|
|
75
|
+
const hash = await computeHash({
|
|
76
|
+
subject: backendExample.subject,
|
|
77
|
+
nonce: backendExample.nonce, // Use nonce string directly
|
|
78
|
+
counter: 0,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
expect(hash).toBeDefined()
|
|
82
|
+
expect(hash.length).toBe(32)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('hashes colon-separated string format like backend', async () => {
|
|
86
|
+
// Test that we're using the backend's expected format: "${subject}:${nonce}:${counter}"
|
|
87
|
+
const nonceString = 'AQIDBA=='
|
|
88
|
+
|
|
89
|
+
const hash = await computeHash({
|
|
90
|
+
subject: 'Lux',
|
|
91
|
+
nonce: nonceString,
|
|
92
|
+
counter: 123,
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// The hash should be of the string "Lux:AQIDBA==:123"
|
|
96
|
+
expect(hash).toBeDefined()
|
|
97
|
+
expect(hash.length).toBe(32)
|
|
98
|
+
|
|
99
|
+
// Verify the expected string format
|
|
100
|
+
const expectedString = `Lux:${nonceString}:123`
|
|
101
|
+
expect(expectedString).toBe('Lux:AQIDBA==:123')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('matches known SHA-256 test vector', async () => {
|
|
105
|
+
// computeHash("Lux:AQIDBA==:123") verified against @noble/hashes/webcrypto SHA-256
|
|
106
|
+
const hash = await computeHash({
|
|
107
|
+
subject: 'Lux',
|
|
108
|
+
nonce: 'AQIDBA==',
|
|
109
|
+
counter: 123,
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const expectedHex = '222c2db479a1ff907a329fc3ff8e99b1f19f0695d938b74ccd95323b9c853510'
|
|
113
|
+
const actualHex = Array.from(hash)
|
|
114
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
115
|
+
.join('')
|
|
116
|
+
|
|
117
|
+
expect(actualHex).toBe(expectedHex)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('findProof', () => {
|
|
122
|
+
it('finds a valid proof for difficulty 1', async () => {
|
|
123
|
+
const challenge: HashcashChallenge = {
|
|
124
|
+
...backendExample,
|
|
125
|
+
difficulty: 1,
|
|
126
|
+
max_proof_length: 10000, // Increase range for testing
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const proof = await findProof({ challenge })
|
|
130
|
+
|
|
131
|
+
expect(proof).not.toBeNull()
|
|
132
|
+
if (proof) {
|
|
133
|
+
expect(proof.counter).toBeDefined()
|
|
134
|
+
expect(proof.hash).toBeDefined()
|
|
135
|
+
expect(proof.attempts).toBeGreaterThan(0)
|
|
136
|
+
expect(checkDifficulty(proof.hash, 1)).toBe(true)
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('respects max_proof_length limit', async () => {
|
|
141
|
+
const challenge: HashcashChallenge = {
|
|
142
|
+
...backendExample,
|
|
143
|
+
difficulty: 20, // High difficulty unlikely to be found
|
|
144
|
+
max_proof_length: 100,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const proof = await findProof({
|
|
148
|
+
challenge,
|
|
149
|
+
rangeSize: challenge.max_proof_length,
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
// Should return null since difficulty 20 (20 zero bytes) is impossible in 100 attempts
|
|
153
|
+
expect(proof).toBeNull()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('returns null when shouldStop signals cancellation', async () => {
|
|
157
|
+
let calls = 0
|
|
158
|
+
const shouldStop = (): boolean => {
|
|
159
|
+
calls++
|
|
160
|
+
// Stop after the first batch boundary check
|
|
161
|
+
return calls >= 2
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const challenge: HashcashChallenge = {
|
|
165
|
+
...backendExample,
|
|
166
|
+
difficulty: 20, // High difficulty so it won't find a proof naturally
|
|
167
|
+
max_proof_length: 100_000,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const proof = await findProof({
|
|
171
|
+
challenge,
|
|
172
|
+
shouldStop,
|
|
173
|
+
batchSize: 64,
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
expect(proof).toBeNull()
|
|
177
|
+
// shouldStop was called at least twice (once to pass, once to cancel)
|
|
178
|
+
expect(calls).toBeGreaterThanOrEqual(2)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('finds proof with custom rangeStart', async () => {
|
|
182
|
+
// First, find a valid proof starting from 0
|
|
183
|
+
const challenge: HashcashChallenge = {
|
|
184
|
+
...backendExample,
|
|
185
|
+
difficulty: 1,
|
|
186
|
+
max_proof_length: 10000,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const baseProof = await findProof({ challenge })
|
|
190
|
+
expect(baseProof).not.toBeNull()
|
|
191
|
+
|
|
192
|
+
if (baseProof) {
|
|
193
|
+
const knownCounter = parseInt(baseProof.counter)
|
|
194
|
+
|
|
195
|
+
// Now search again starting from that known counter
|
|
196
|
+
const proof = await findProof({
|
|
197
|
+
challenge,
|
|
198
|
+
rangeStart: knownCounter,
|
|
199
|
+
rangeSize: 1, // Only check the one counter
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
expect(proof).not.toBeNull()
|
|
203
|
+
expect(proof!.counter).toBe(knownCounter.toString())
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
describe('verifyProof', () => {
|
|
209
|
+
it('verifies a valid proof', async () => {
|
|
210
|
+
const challenge: HashcashChallenge = {
|
|
211
|
+
...backendExample,
|
|
212
|
+
difficulty: 1,
|
|
213
|
+
max_proof_length: 10000,
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// First find a proof
|
|
217
|
+
const proof = await findProof({ challenge })
|
|
218
|
+
expect(proof).not.toBeNull()
|
|
219
|
+
|
|
220
|
+
if (proof) {
|
|
221
|
+
// Then verify it
|
|
222
|
+
const isValid = await verifyProof(challenge, proof.counter)
|
|
223
|
+
expect(isValid).toBe(true)
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('rejects an invalid proof', async () => {
|
|
228
|
+
// Test with non-numeric counter (always invalid)
|
|
229
|
+
expect(await verifyProof(backendExample, 'not-a-number')).toBe(false)
|
|
230
|
+
|
|
231
|
+
// Test with higher difficulty where most counters are invalid
|
|
232
|
+
const higherDifficultyChallenge = {
|
|
233
|
+
...backendExample,
|
|
234
|
+
difficulty: 16, // 2 full zero bytes required
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// This counter is very unlikely to produce 2 zero bytes
|
|
238
|
+
const isValid = await verifyProof(higherDifficultyChallenge, '12345')
|
|
239
|
+
expect(isValid).toBe(false)
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
describe('formatHashcashString', () => {
|
|
244
|
+
it('formats hashcash string correctly', () => {
|
|
245
|
+
const proof = {
|
|
246
|
+
counter: '123',
|
|
247
|
+
hash: new Uint8Array(32).fill(0),
|
|
248
|
+
attempts: 123,
|
|
249
|
+
timeMs: 10,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const formatted = formatHashcashString(backendExample, proof)
|
|
253
|
+
|
|
254
|
+
// Check format: version:bits:date:resource:extension:counter:hash
|
|
255
|
+
const parts = formatted.split(':')
|
|
256
|
+
|
|
257
|
+
expect(parts.length).toBe(7)
|
|
258
|
+
expect(parts[0]).toBe('1') // Version
|
|
259
|
+
expect(parts[1]).toBe('1') // Difficulty
|
|
260
|
+
expect(parts[2]).toMatch(/^\d{6}$/) // Date format YYMMDD (always 6 digits)
|
|
261
|
+
expect(parts[3]).toBe('Lux') // Resource
|
|
262
|
+
expect(parts[4]).toBe('') // Extension (empty)
|
|
263
|
+
expect(parts[5]).toBe('123') // Counter
|
|
264
|
+
expect(parts[6]).toMatch(/^[A-Za-z0-9+/=]+$/) // Base64 hash
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
describe('integration with backend example', () => {
|
|
269
|
+
it('completes full hashcash flow with real backend data', async () => {
|
|
270
|
+
const challenge: HashcashChallenge = {
|
|
271
|
+
...backendExample,
|
|
272
|
+
max_proof_length: 10000, // Give enough range to find solution
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Step 1: Find proof
|
|
276
|
+
const proof = await findProof({ challenge })
|
|
277
|
+
expect(proof).not.toBeNull()
|
|
278
|
+
|
|
279
|
+
if (proof) {
|
|
280
|
+
// Step 2: Verify the proof is valid
|
|
281
|
+
const isValid = await verifyProof(challenge, proof.counter)
|
|
282
|
+
expect(isValid).toBe(true)
|
|
283
|
+
|
|
284
|
+
// Step 3: Check the hash meets difficulty requirement
|
|
285
|
+
expect(checkDifficulty(proof.hash, challenge.difficulty)).toBe(true)
|
|
286
|
+
|
|
287
|
+
// Step 4: Format for submission
|
|
288
|
+
const hashcashString = formatHashcashString(challenge, proof)
|
|
289
|
+
expect(hashcashString).toBeTruthy()
|
|
290
|
+
|
|
291
|
+
// Verify format includes our subject
|
|
292
|
+
expect(hashcashString).toContain('Lux')
|
|
293
|
+
}
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('handles backend difficulty vs verifier discrepancy', async () => {
|
|
297
|
+
// The backend example shows difficulty: 1
|
|
298
|
+
// But the verifier checks: hash.slice(0,1).every(x => x === 0)
|
|
299
|
+
// which actually requires the first byte to be 0 (difficulty 8)
|
|
300
|
+
|
|
301
|
+
const challenge = backendExample
|
|
302
|
+
const proof = await findProof({
|
|
303
|
+
challenge,
|
|
304
|
+
rangeSize: 10000,
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
if (proof) {
|
|
308
|
+
// Check our difficulty 1 validation
|
|
309
|
+
const meetsSpecifiedDifficulty = checkDifficulty(proof.hash, 1)
|
|
310
|
+
expect(meetsSpecifiedDifficulty).toBe(true)
|
|
311
|
+
}
|
|
312
|
+
})
|
|
313
|
+
})
|
|
314
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base stub for hashcash core functions.
|
|
3
|
+
* Platform-specific implementations override this file.
|
|
4
|
+
*
|
|
5
|
+
* - Web: core.web.ts (Web Crypto + batching)
|
|
6
|
+
* - Native: core.native.ts (mobile uses Nitro modules)
|
|
7
|
+
*
|
|
8
|
+
* Shared types and platform-agnostic functions live in shared.ts.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { PlatformSplitStubError } from '@luxfi/utilities/src/errors'
|
|
12
|
+
|
|
13
|
+
export type { HashcashChallenge, ProofResult } from '@luxexchange/sessions/src/challenge-solvers/hashcash/shared'
|
|
14
|
+
// Re-export everything from shared — types, checkDifficulty, formatHashcashString
|
|
15
|
+
export { checkDifficulty, formatHashcashString } from '@luxexchange/sessions/src/challenge-solvers/hashcash/shared'
|
|
16
|
+
|
|
17
|
+
import type { HashcashChallenge, ProofResult } from '@luxexchange/sessions/src/challenge-solvers/hashcash/shared'
|
|
18
|
+
|
|
19
|
+
export async function computeHash(_params: { subject: string; nonce: string; counter: number }): Promise<Uint8Array> {
|
|
20
|
+
throw new PlatformSplitStubError('computeHash')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function findProof(_params: {
|
|
24
|
+
challenge: HashcashChallenge
|
|
25
|
+
rangeStart?: number
|
|
26
|
+
rangeSize?: number
|
|
27
|
+
shouldStop?: () => boolean
|
|
28
|
+
batchSize?: number
|
|
29
|
+
}): Promise<ProofResult | null> {
|
|
30
|
+
throw new PlatformSplitStubError('findProof')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function verifyProof(_challenge: HashcashChallenge, _proofCounter: string): Promise<boolean> {
|
|
34
|
+
throw new PlatformSplitStubError('verifyProof')
|
|
35
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web-optimized hashcash core implementation.
|
|
3
|
+
*
|
|
4
|
+
* Uses Web Crypto API via @noble/hashes/webcrypto.js for hardware-accelerated
|
|
5
|
+
* SHA-256 hashing. Includes batching to amortize async overhead.
|
|
6
|
+
*
|
|
7
|
+
* This is the web platform implementation - mobile uses native Nitro modules.
|
|
8
|
+
*/
|
|
9
|
+
import { sha256 } from '@noble/hashes/webcrypto.js'
|
|
10
|
+
|
|
11
|
+
export type { HashcashChallenge, ProofResult } from '@luxexchange/sessions/src/challenge-solvers/hashcash/shared'
|
|
12
|
+
// Re-export shared types and platform-agnostic functions
|
|
13
|
+
export { checkDifficulty, formatHashcashString } from '@luxexchange/sessions/src/challenge-solvers/hashcash/shared'
|
|
14
|
+
|
|
15
|
+
import type { HashcashChallenge, ProofResult } from '@luxexchange/sessions/src/challenge-solvers/hashcash/shared'
|
|
16
|
+
import { checkDifficulty } from '@luxexchange/sessions/src/challenge-solvers/hashcash/shared'
|
|
17
|
+
|
|
18
|
+
// Pre-allocated TextEncoder for memory efficiency (avoids creating new instance per hash)
|
|
19
|
+
const encoder = new TextEncoder()
|
|
20
|
+
|
|
21
|
+
// Default batch size for async hashing
|
|
22
|
+
// Tuned to balance async overhead vs responsiveness to cancellation
|
|
23
|
+
const DEFAULT_BATCH_SIZE = 256
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Compute SHA-256 hash using Web Crypto API.
|
|
27
|
+
* Async for hardware acceleration.
|
|
28
|
+
*/
|
|
29
|
+
export async function computeHash(params: { subject: string; nonce: string; counter: number }): Promise<Uint8Array> {
|
|
30
|
+
const { subject, nonce, counter } = params
|
|
31
|
+
|
|
32
|
+
// Backend expects: "${subject}:${nonce}:${counter}"
|
|
33
|
+
const solutionString = `${subject}:${nonce}:${counter}`
|
|
34
|
+
|
|
35
|
+
// Hash using Web Crypto (hardware-accelerated)
|
|
36
|
+
const inputBytes = encoder.encode(solutionString)
|
|
37
|
+
return sha256(inputBytes)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Find a proof-of-work solution using batched Web Crypto hashing.
|
|
42
|
+
*
|
|
43
|
+
* Processes hashes in batches to amortize async overhead while remaining
|
|
44
|
+
* responsive to cancellation requests.
|
|
45
|
+
*/
|
|
46
|
+
export async function findProof(params: {
|
|
47
|
+
challenge: HashcashChallenge
|
|
48
|
+
rangeStart?: number
|
|
49
|
+
rangeSize?: number
|
|
50
|
+
shouldStop?: () => boolean
|
|
51
|
+
batchSize?: number
|
|
52
|
+
}): Promise<ProofResult | null> {
|
|
53
|
+
const {
|
|
54
|
+
challenge,
|
|
55
|
+
rangeStart = 0,
|
|
56
|
+
rangeSize = challenge.max_proof_length || 1_000_000,
|
|
57
|
+
shouldStop,
|
|
58
|
+
batchSize = DEFAULT_BATCH_SIZE,
|
|
59
|
+
} = params
|
|
60
|
+
|
|
61
|
+
const startTime = Date.now()
|
|
62
|
+
const rangeEnd = rangeStart + rangeSize
|
|
63
|
+
const { subject, nonce, difficulty } = challenge
|
|
64
|
+
|
|
65
|
+
for (let batchStart = rangeStart; batchStart < rangeEnd; batchStart += batchSize) {
|
|
66
|
+
// Check for cancellation at batch boundaries
|
|
67
|
+
if (shouldStop?.()) {
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Determine actual batch end (don't exceed range)
|
|
72
|
+
const batchEnd = Math.min(batchStart + batchSize, rangeEnd)
|
|
73
|
+
const currentBatchSize = batchEnd - batchStart
|
|
74
|
+
|
|
75
|
+
// Generate counter values for this batch
|
|
76
|
+
const counters: number[] = new Array(currentBatchSize)
|
|
77
|
+
for (let i = 0; i < currentBatchSize; i++) {
|
|
78
|
+
counters[i] = batchStart + i
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Hash all counters in this batch in parallel
|
|
82
|
+
const hashes = await Promise.all(counters.map((counter) => computeHash({ subject, nonce, counter })))
|
|
83
|
+
|
|
84
|
+
// Check each hash for valid proof
|
|
85
|
+
for (let i = 0; i < hashes.length; i++) {
|
|
86
|
+
const hash = hashes[i]
|
|
87
|
+
const counter = counters[i]
|
|
88
|
+
// Safety check (shouldn't happen since arrays are same size)
|
|
89
|
+
if (!hash || counter === undefined) {
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
if (checkDifficulty(hash, difficulty)) {
|
|
93
|
+
return {
|
|
94
|
+
counter: counter.toString(),
|
|
95
|
+
hash,
|
|
96
|
+
attempts: counter - rangeStart + 1,
|
|
97
|
+
timeMs: Date.now() - startTime,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Verify a proof solution.
|
|
108
|
+
* Async because it uses Web Crypto.
|
|
109
|
+
*/
|
|
110
|
+
export async function verifyProof(challenge: HashcashChallenge, proofCounter: string): Promise<boolean> {
|
|
111
|
+
const counter = parseInt(proofCounter)
|
|
112
|
+
|
|
113
|
+
if (isNaN(counter)) {
|
|
114
|
+
return false
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const hash = await computeHash({
|
|
118
|
+
subject: challenge.subject,
|
|
119
|
+
nonce: challenge.nonce,
|
|
120
|
+
counter,
|
|
121
|
+
})
|
|
122
|
+
return checkDifficulty(hash, challenge.difficulty)
|
|
123
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { ChallengeType } from '@uniswap/client-platform-service/dist/uniswap/platformservice/v1/sessionService_pb'
|
|
2
|
+
import { createWorkerHashcashSolver } from '@luxexchange/sessions/src/challenge-solvers/hashcash/createWorkerHashcashSolver'
|
|
3
|
+
import type { HashcashWorkerChannelFactory } from '@luxexchange/sessions/src/challenge-solvers/hashcash/worker/types'
|
|
4
|
+
import type { ChallengeData } from '@luxexchange/sessions/src/challenge-solvers/types'
|
|
5
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
6
|
+
|
|
7
|
+
describe('createWorkerHashcashSolver', () => {
|
|
8
|
+
// Real backend example data
|
|
9
|
+
const backendExample = {
|
|
10
|
+
difficulty: 1,
|
|
11
|
+
subject: 'Lux',
|
|
12
|
+
algorithm: 'sha256' as const,
|
|
13
|
+
nonce: 'Qlquffem7d8RrL6fmveE68XK0KxcoczdiVpFrV1qeUk=',
|
|
14
|
+
max_proof_length: 10000,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Mock channel factory for testing
|
|
18
|
+
function createMockChannelFactory(
|
|
19
|
+
findProofResult: { counter: string; hash: Uint8Array; attempts: number; timeMs: number } | null = {
|
|
20
|
+
counter: '123',
|
|
21
|
+
hash: new Uint8Array([0, 0, 1, 2, 3]),
|
|
22
|
+
attempts: 124,
|
|
23
|
+
timeMs: 50,
|
|
24
|
+
},
|
|
25
|
+
): {
|
|
26
|
+
factory: HashcashWorkerChannelFactory
|
|
27
|
+
mocks: {
|
|
28
|
+
findProof: ReturnType<typeof vi.fn>
|
|
29
|
+
cancel: ReturnType<typeof vi.fn>
|
|
30
|
+
terminate: ReturnType<typeof vi.fn>
|
|
31
|
+
}
|
|
32
|
+
} {
|
|
33
|
+
const findProof = vi.fn().mockResolvedValue(findProofResult)
|
|
34
|
+
const cancel = vi.fn().mockResolvedValue(undefined)
|
|
35
|
+
const terminate = vi.fn()
|
|
36
|
+
|
|
37
|
+
const factory: HashcashWorkerChannelFactory = () => ({
|
|
38
|
+
api: { findProof, cancel },
|
|
39
|
+
terminate,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
return { factory, mocks: { findProof, cancel, terminate } }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
vi.clearAllMocks()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('successfully solves a valid challenge using worker', async () => {
|
|
50
|
+
const { factory, mocks } = createMockChannelFactory()
|
|
51
|
+
const solver = createWorkerHashcashSolver({ createChannel: factory })
|
|
52
|
+
|
|
53
|
+
const challengeData: ChallengeData = {
|
|
54
|
+
challengeId: 'test-challenge-123',
|
|
55
|
+
challengeType: ChallengeType.HASHCASH,
|
|
56
|
+
extra: {
|
|
57
|
+
challengeData: JSON.stringify(backendExample),
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const solution = await solver.solve(challengeData)
|
|
62
|
+
|
|
63
|
+
// Check format: "${subject}:${nonce}:${counter}"
|
|
64
|
+
expect(solution).toBe(`Lux:${backendExample.nonce}:123`)
|
|
65
|
+
|
|
66
|
+
// Verify worker was called correctly
|
|
67
|
+
expect(mocks.findProof).toHaveBeenCalledOnce()
|
|
68
|
+
expect(mocks.findProof).toHaveBeenCalledWith({
|
|
69
|
+
challenge: expect.objectContaining({
|
|
70
|
+
subject: 'Lux',
|
|
71
|
+
difficulty: 1,
|
|
72
|
+
nonce: backendExample.nonce,
|
|
73
|
+
}),
|
|
74
|
+
rangeStart: 0,
|
|
75
|
+
rangeSize: 10000,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// Verify channel was terminated
|
|
79
|
+
expect(mocks.terminate).toHaveBeenCalledOnce()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('throws error when challengeData is missing', async () => {
|
|
83
|
+
const { factory } = createMockChannelFactory()
|
|
84
|
+
const solver = createWorkerHashcashSolver({ createChannel: factory })
|
|
85
|
+
|
|
86
|
+
const challengeData: ChallengeData = {
|
|
87
|
+
challengeId: 'test-challenge-123',
|
|
88
|
+
challengeType: ChallengeType.HASHCASH,
|
|
89
|
+
extra: {}, // Missing challengeData
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
await expect(solver.solve(challengeData)).rejects.toThrow('Missing challengeData in challenge extra field')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('throws error when no proof is found', async () => {
|
|
96
|
+
const { factory } = createMockChannelFactory(null)
|
|
97
|
+
const solver = createWorkerHashcashSolver({ createChannel: factory })
|
|
98
|
+
|
|
99
|
+
const challengeData: ChallengeData = {
|
|
100
|
+
challengeId: 'test-challenge-123',
|
|
101
|
+
challengeType: ChallengeType.HASHCASH,
|
|
102
|
+
extra: {
|
|
103
|
+
challengeData: JSON.stringify(backendExample),
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
await expect(solver.solve(challengeData)).rejects.toThrow('Failed to find valid proof within allowed range')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('terminates channel even when error occurs', async () => {
|
|
111
|
+
const findProof = vi.fn().mockRejectedValue(new Error('Worker error'))
|
|
112
|
+
const terminate = vi.fn()
|
|
113
|
+
|
|
114
|
+
const factory: HashcashWorkerChannelFactory = () => ({
|
|
115
|
+
api: { findProof, cancel: vi.fn() },
|
|
116
|
+
terminate,
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const solver = createWorkerHashcashSolver({ createChannel: factory })
|
|
120
|
+
|
|
121
|
+
const challengeData: ChallengeData = {
|
|
122
|
+
challengeId: 'test-challenge-123',
|
|
123
|
+
challengeType: ChallengeType.HASHCASH,
|
|
124
|
+
extra: {
|
|
125
|
+
challengeData: JSON.stringify(backendExample),
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
await expect(solver.solve(challengeData)).rejects.toThrow('Worker error')
|
|
130
|
+
|
|
131
|
+
// Channel should still be terminated
|
|
132
|
+
expect(terminate).toHaveBeenCalledOnce()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe('cancellation', () => {
|
|
136
|
+
it('cancels solving when AbortSignal is triggered', async () => {
|
|
137
|
+
const controller = new AbortController()
|
|
138
|
+
const { factory, mocks } = createMockChannelFactory()
|
|
139
|
+
|
|
140
|
+
// Make findProof take a while and check abort during it
|
|
141
|
+
mocks.findProof.mockImplementation(async () => {
|
|
142
|
+
// Simulate work, then check if cancelled
|
|
143
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
144
|
+
return null // Simulates cancelled result
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
const solver = createWorkerHashcashSolver({
|
|
148
|
+
createChannel: factory,
|
|
149
|
+
signal: controller.signal,
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const challengeData: ChallengeData = {
|
|
153
|
+
challengeId: 'test-challenge-123',
|
|
154
|
+
challengeType: ChallengeType.HASHCASH,
|
|
155
|
+
extra: {
|
|
156
|
+
challengeData: JSON.stringify(backendExample),
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Start solving and cancel after a short delay
|
|
161
|
+
const solvePromise = solver.solve(challengeData)
|
|
162
|
+
controller.abort()
|
|
163
|
+
|
|
164
|
+
await expect(solvePromise).rejects.toThrow('Challenge solving was cancelled')
|
|
165
|
+
|
|
166
|
+
// Verify cancel was called on the worker
|
|
167
|
+
expect(mocks.cancel).toHaveBeenCalled()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('throws immediately if already aborted', async () => {
|
|
171
|
+
const controller = new AbortController()
|
|
172
|
+
controller.abort() // Abort before starting
|
|
173
|
+
|
|
174
|
+
const { factory, mocks } = createMockChannelFactory()
|
|
175
|
+
|
|
176
|
+
const solver = createWorkerHashcashSolver({
|
|
177
|
+
createChannel: factory,
|
|
178
|
+
signal: controller.signal,
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
const challengeData: ChallengeData = {
|
|
182
|
+
challengeId: 'test-challenge-123',
|
|
183
|
+
challengeType: ChallengeType.HASHCASH,
|
|
184
|
+
extra: {
|
|
185
|
+
challengeData: JSON.stringify(backendExample),
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
await expect(solver.solve(challengeData)).rejects.toThrow('Challenge solving was cancelled')
|
|
190
|
+
|
|
191
|
+
// Worker should not be called if already aborted
|
|
192
|
+
expect(mocks.findProof).not.toHaveBeenCalled()
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
})
|