@l.x/sessions 1.0.3 → 1.0.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.
Files changed (78) hide show
  1. package/.depcheckrc +20 -0
  2. package/.eslintrc.js +21 -0
  3. package/LICENSE +122 -0
  4. package/README.md +1 -0
  5. package/env.d.ts +12 -0
  6. package/package.json +49 -1
  7. package/project.json +36 -0
  8. package/src/challenge-solvers/createChallengeSolverService.ts +64 -0
  9. package/src/challenge-solvers/createHashcashMockSolver.ts +39 -0
  10. package/src/challenge-solvers/createHashcashSolver.test.ts +385 -0
  11. package/src/challenge-solvers/createHashcashSolver.ts +270 -0
  12. package/src/challenge-solvers/createNoneMockSolver.ts +11 -0
  13. package/src/challenge-solvers/createTurnstileMockSolver.ts +30 -0
  14. package/src/challenge-solvers/createTurnstileSolver.ts +357 -0
  15. package/src/challenge-solvers/hashcash/core.native.ts +34 -0
  16. package/src/challenge-solvers/hashcash/core.test.ts +314 -0
  17. package/src/challenge-solvers/hashcash/core.ts +35 -0
  18. package/src/challenge-solvers/hashcash/core.web.ts +123 -0
  19. package/src/challenge-solvers/hashcash/createWorkerHashcashSolver.test.ts +195 -0
  20. package/src/challenge-solvers/hashcash/createWorkerHashcashSolver.ts +120 -0
  21. package/src/challenge-solvers/hashcash/shared.ts +70 -0
  22. package/src/challenge-solvers/hashcash/worker/createHashcashMultiWorkerChannel.native.ts +22 -0
  23. package/src/challenge-solvers/hashcash/worker/createHashcashMultiWorkerChannel.ts +22 -0
  24. package/src/challenge-solvers/hashcash/worker/createHashcashMultiWorkerChannel.web.ts +212 -0
  25. package/src/challenge-solvers/hashcash/worker/createHashcashWorkerChannel.native.ts +16 -0
  26. package/src/challenge-solvers/hashcash/worker/createHashcashWorkerChannel.ts +16 -0
  27. package/src/challenge-solvers/hashcash/worker/createHashcashWorkerChannel.web.ts +97 -0
  28. package/src/challenge-solvers/hashcash/worker/hashcash.worker.ts +91 -0
  29. package/src/challenge-solvers/hashcash/worker/types.ts +69 -0
  30. package/src/challenge-solvers/turnstileErrors.ts +49 -0
  31. package/src/challenge-solvers/turnstileScriptLoader.ts +325 -0
  32. package/src/challenge-solvers/turnstileSolver.integration.test.ts +331 -0
  33. package/src/challenge-solvers/types.ts +55 -0
  34. package/src/challengeFlow.integration.test.ts +627 -0
  35. package/src/device-id/createDeviceIdService.ts +19 -0
  36. package/src/device-id/types.ts +11 -0
  37. package/src/index.ts +139 -0
  38. package/src/lx-identifier/createLXIdentifierService.ts +1 -0
  39. package/src/lx-identifier/createUniswapIdentifierService.ts +19 -0
  40. package/src/lx-identifier/lxIdentifierQuery.ts +1 -0
  41. package/src/lx-identifier/types.ts +11 -0
  42. package/src/lx-identifier/uniswapIdentifierQuery.ts +20 -0
  43. package/src/oauth-service/createOAuthService.ts +125 -0
  44. package/src/oauth-service/types.ts +104 -0
  45. package/src/performance/createNoopPerformanceTracker.ts +12 -0
  46. package/src/performance/createPerformanceTracker.ts +43 -0
  47. package/src/performance/index.ts +7 -0
  48. package/src/performance/types.ts +11 -0
  49. package/src/session-initialization/createSessionInitializationService.test.ts +557 -0
  50. package/src/session-initialization/createSessionInitializationService.ts +184 -0
  51. package/src/session-initialization/sessionErrors.ts +32 -0
  52. package/src/session-repository/createSessionClient.ts +10 -0
  53. package/src/session-repository/createSessionRepository.test.ts +313 -0
  54. package/src/session-repository/createSessionRepository.ts +242 -0
  55. package/src/session-repository/errors.ts +22 -0
  56. package/src/session-repository/types.ts +289 -0
  57. package/src/session-service/createNoopSessionService.ts +24 -0
  58. package/src/session-service/createSessionService.test.ts +388 -0
  59. package/src/session-service/createSessionService.ts +61 -0
  60. package/src/session-service/types.ts +59 -0
  61. package/src/session-storage/createSessionStorage.ts +28 -0
  62. package/src/session-storage/types.ts +15 -0
  63. package/src/session.integration.test.ts +516 -0
  64. package/src/sessionLifecycle.integration.test.ts +264 -0
  65. package/src/test-utils/createLocalCookieTransport.ts +52 -0
  66. package/src/test-utils/createLocalHeaderTransport.ts +45 -0
  67. package/src/test-utils/mocks.ts +122 -0
  68. package/src/test-utils.ts +200 -0
  69. package/src/uniswap-identifier/createUniswapIdentifierService.ts +19 -0
  70. package/src/uniswap-identifier/types.ts +11 -0
  71. package/src/uniswap-identifier/uniswapIdentifierQuery.ts +20 -0
  72. package/tsconfig.json +26 -0
  73. package/tsconfig.lint.json +8 -0
  74. package/tsconfig.spec.json +8 -0
  75. package/vitest.config.ts +20 -0
  76. package/vitest.integration.config.ts +14 -0
  77. package/index.d.ts +0 -1
  78. package/index.js +0 -1
@@ -0,0 +1,357 @@
1
+ import {
2
+ TurnstileApiNotAvailableError,
3
+ TurnstileError,
4
+ TurnstileScriptLoadError,
5
+ TurnstileTimeoutError,
6
+ TurnstileTokenExpiredError,
7
+ } from '@l.x/sessions/src/challenge-solvers/turnstileErrors'
8
+ import { ensureTurnstileScript } from '@l.x/sessions/src/challenge-solvers/turnstileScriptLoader'
9
+ import type {
10
+ ChallengeData,
11
+ ChallengeSolver,
12
+ TurnstileScriptOptions,
13
+ } from '@l.x/sessions/src/challenge-solvers/types'
14
+ import type { PerformanceTracker } from '@l.x/sessions/src/performance/types'
15
+ import type { Logger } from 'utilities/src/logger/logger'
16
+
17
+ /**
18
+ * Analytics data for Turnstile solve attempts.
19
+ * Reported via onSolveCompleted callback.
20
+ */
21
+ interface TurnstileSolveAnalytics {
22
+ durationMs: number
23
+ success: boolean
24
+ errorType?: 'timeout' | 'script_load' | 'network' | 'validation' | 'unknown'
25
+ errorMessage?: string
26
+ }
27
+
28
+ // Declare Turnstile types inline to avoid import issues
29
+ interface TurnstileWidget {
30
+ render: (container: string | HTMLElement, options: TurnstileOptions) => string
31
+ remove: (widgetId: string) => void
32
+ reset: (widgetId: string) => void
33
+ getResponse: (widgetId: string) => string | undefined
34
+ ready: (callback: () => void) => void
35
+ }
36
+
37
+ interface TurnstileOptions {
38
+ sitekey: string
39
+ action?: string
40
+ theme?: 'light' | 'dark' | 'auto'
41
+ size?: 'normal' | 'compact' | 'flexible'
42
+ callback?: (token: string) => void
43
+ 'error-callback'?: (error: string) => void
44
+ 'expired-callback'?: () => void
45
+ }
46
+
47
+ // Extend the Window interface to include turnstile
48
+ declare global {
49
+ interface Window {
50
+ turnstile?: TurnstileWidget
51
+ }
52
+ }
53
+
54
+ interface CreateTurnstileSolverContext {
55
+ /**
56
+ * Required: Performance tracker for timing measurements.
57
+ * Must be injected - no implicit dependency on globalThis.performance.
58
+ */
59
+ performanceTracker: PerformanceTracker
60
+ /**
61
+ * Optional logger for debugging
62
+ */
63
+ getLogger?: () => Logger
64
+ /**
65
+ * Optional script injection options for CSP compliance
66
+ */
67
+ scriptOptions?: TurnstileScriptOptions
68
+ /**
69
+ * Widget rendering timeout in milliseconds
70
+ * @default 30000
71
+ */
72
+ timeoutMs?: number
73
+ /**
74
+ * Callback for analytics when solve completes (success or failure)
75
+ */
76
+ onSolveCompleted?: (data: TurnstileSolveAnalytics) => void
77
+ }
78
+
79
+ /**
80
+ * Classifies error into analytics error type
81
+ */
82
+ function classifyError(error: unknown): TurnstileSolveAnalytics['errorType'] {
83
+ if (error instanceof TurnstileTimeoutError || error instanceof TurnstileTokenExpiredError) {
84
+ return 'timeout'
85
+ }
86
+ if (error instanceof TurnstileScriptLoadError || error instanceof TurnstileApiNotAvailableError) {
87
+ return 'script_load'
88
+ }
89
+ if (error instanceof TurnstileError) {
90
+ return 'network'
91
+ }
92
+ if (error instanceof Error && error.message.includes('parse')) {
93
+ return 'validation'
94
+ }
95
+ if (error instanceof Error && error.message.includes('Missing')) {
96
+ return 'validation'
97
+ }
98
+ return 'unknown'
99
+ }
100
+
101
+ /**
102
+ * Creates a Turnstile challenge solver.
103
+ *
104
+ * This integrates with Cloudflare Turnstile using explicit rendering:
105
+ * - Dynamically loads Turnstile script if not present (with state management)
106
+ * - Creates a temporary DOM container
107
+ * - Renders widget with sitekey and action from challengeData.extra
108
+ * - Returns the verification token from Turnstile API
109
+ *
110
+ * Features:
111
+ * - Separation of concerns: Script loading separated from widget rendering
112
+ * - Dependency injection: Logger and script options injected via context
113
+ * - Contract-based design: Implements ChallengeSolver interface
114
+ * - Factory pattern: Returns solver instance, not component
115
+ */
116
+ function createTurnstileSolver(ctx: CreateTurnstileSolverContext): ChallengeSolver {
117
+ async function solve(challengeData: ChallengeData): Promise<string> {
118
+ const startTime = ctx.performanceTracker.now()
119
+
120
+ ctx.getLogger?.().debug('createTurnstileSolver', 'solve', 'Solving Turnstile challenge', { challengeData })
121
+
122
+ // Extract challenge data — prefer typed challengeData over legacy extra field
123
+ let siteKey: string
124
+ let action: string | undefined
125
+
126
+ if (challengeData.challengeData?.case === 'turnstile') {
127
+ siteKey = challengeData.challengeData.value.siteKey
128
+ action = challengeData.challengeData.value.action
129
+ } else {
130
+ // Fallback to legacy extra field
131
+ const challengeDataStr = challengeData.extra?.['challengeData']
132
+ if (!challengeDataStr) {
133
+ const error = new Error('Missing challengeData in challenge extra')
134
+ ctx.onSolveCompleted?.({
135
+ durationMs: ctx.performanceTracker.now() - startTime,
136
+ success: false,
137
+ errorType: 'validation',
138
+ errorMessage: error.message,
139
+ })
140
+ throw error
141
+ }
142
+
143
+ let parsedData: { siteKey: string; action: string }
144
+ try {
145
+ parsedData = JSON.parse(challengeDataStr)
146
+ } catch (error) {
147
+ const parseError = new Error('Failed to parse challengeData', { cause: error })
148
+ ctx.onSolveCompleted?.({
149
+ durationMs: ctx.performanceTracker.now() - startTime,
150
+ success: false,
151
+ errorType: 'validation',
152
+ errorMessage: parseError.message,
153
+ })
154
+ throw parseError
155
+ }
156
+
157
+ siteKey = parsedData.siteKey
158
+ action = parsedData.action
159
+ }
160
+
161
+ if (!siteKey) {
162
+ const error = new Error('Missing siteKey in challengeData')
163
+ ctx.onSolveCompleted?.({
164
+ durationMs: ctx.performanceTracker.now() - startTime,
165
+ success: false,
166
+ errorType: 'validation',
167
+ errorMessage: error.message,
168
+ })
169
+ throw error
170
+ }
171
+
172
+ ctx.getLogger?.().debug('createTurnstileSolver', 'solve', 'Parsed challengeData', { siteKey, action })
173
+
174
+ await ensureTurnstileScript(ctx.scriptOptions)
175
+
176
+ ctx.getLogger?.().debug('createTurnstileSolver', 'solve', 'Turnstile script loaded')
177
+
178
+ // Verify Turnstile API is available
179
+ if (!window.turnstile) {
180
+ const error = new TurnstileApiNotAvailableError()
181
+ ctx.onSolveCompleted?.({
182
+ durationMs: ctx.performanceTracker.now() - startTime,
183
+ success: false,
184
+ errorType: 'script_load',
185
+ errorMessage: error.message,
186
+ })
187
+ throw error
188
+ }
189
+
190
+ // Create temporary container for the widget
191
+ const containerId = `turnstile-${challengeData.challengeId}`
192
+ const container = document.createElement('div')
193
+ container.id = containerId
194
+ container.style.position = 'fixed'
195
+ container.style.top = '-9999px' // Hide off-screen
196
+ container.style.left = '-9999px'
197
+ container.setAttribute('aria-hidden', 'true') // Accessibility
198
+ document.body.appendChild(container)
199
+
200
+ const timeoutMs = ctx.timeoutMs ?? 30000
201
+ const cleanupState = {
202
+ timeoutId: null as ReturnType<typeof setTimeout> | null,
203
+ widgetId: null as string | null,
204
+ }
205
+
206
+ try {
207
+ // Wait for Turnstile to be ready and render widget
208
+ const token = await new Promise<string>((resolve, reject) => {
209
+ // Set up timeout with proper cleanup
210
+ cleanupState.timeoutId = setTimeout(() => {
211
+ if (cleanupState.widgetId && window.turnstile) {
212
+ try {
213
+ window.turnstile.remove(cleanupState.widgetId)
214
+ } catch {
215
+ // Ignore cleanup errors
216
+ }
217
+ }
218
+ reject(new TurnstileTimeoutError(timeoutMs))
219
+ }, timeoutMs)
220
+
221
+ // Helper function to render the widget
222
+ const renderWidget = (): void => {
223
+ if (!window.turnstile) {
224
+ reject(new TurnstileApiNotAvailableError())
225
+ return
226
+ }
227
+
228
+ try {
229
+ cleanupState.widgetId = window.turnstile.render(container, {
230
+ sitekey: siteKey,
231
+ action,
232
+ theme: 'light',
233
+ size: 'normal',
234
+ callback: (tokenValue: string) => {
235
+ if (cleanupState.timeoutId) {
236
+ clearTimeout(cleanupState.timeoutId)
237
+ cleanupState.timeoutId = null
238
+ }
239
+ ctx.getLogger?.().debug('createTurnstileSolver', 'solve', 'Turnstile token resolved', {
240
+ tokenValue,
241
+ })
242
+ resolve(tokenValue)
243
+ },
244
+ 'error-callback': (error: string) => {
245
+ if (cleanupState.timeoutId) {
246
+ clearTimeout(cleanupState.timeoutId)
247
+ cleanupState.timeoutId = null
248
+ }
249
+ ctx.getLogger?.().debug('createTurnstileSolver', 'solve', 'Turnstile error', { error })
250
+ reject(new TurnstileError(error))
251
+ },
252
+ 'expired-callback': () => {
253
+ if (cleanupState.timeoutId) {
254
+ clearTimeout(cleanupState.timeoutId)
255
+ cleanupState.timeoutId = null
256
+ }
257
+ ctx.getLogger?.().debug('createTurnstileSolver', 'solve', 'Turnstile token expired')
258
+ reject(new TurnstileTokenExpiredError())
259
+ },
260
+ })
261
+
262
+ ctx.getLogger?.().debug('createTurnstileSolver', 'solve', 'Turnstile widget rendered', {
263
+ widgetId: cleanupState.widgetId,
264
+ })
265
+ } catch (error) {
266
+ if (cleanupState.timeoutId) {
267
+ clearTimeout(cleanupState.timeoutId)
268
+ cleanupState.timeoutId = null
269
+ }
270
+ ctx.getLogger?.().error(error, {
271
+ tags: {
272
+ file: 'createTurnstileSolver.ts',
273
+ function: 'solve',
274
+ },
275
+ })
276
+ reject(
277
+ new TurnstileError(`Failed to render widget: ${error instanceof Error ? error.message : String(error)}`),
278
+ )
279
+ }
280
+ }
281
+
282
+ if (!window.turnstile) {
283
+ reject(new TurnstileApiNotAvailableError())
284
+ return
285
+ }
286
+
287
+ try {
288
+ window.turnstile.ready(() => {
289
+ renderWidget()
290
+ })
291
+ } catch (error) {
292
+ // Fallback: render directly if ready() throws
293
+ ctx.getLogger?.().debug('createTurnstileSolver', 'solve', 'turnstile.ready() failed, rendering directly', {
294
+ error: error instanceof Error ? error.message : String(error),
295
+ })
296
+ renderWidget()
297
+ }
298
+ })
299
+
300
+ // Report success
301
+ const data: TurnstileSolveAnalytics = {
302
+ durationMs: ctx.performanceTracker.now() - startTime,
303
+ success: true,
304
+ }
305
+ ctx.onSolveCompleted?.(data)
306
+ ctx.getLogger?.().info('sessions', 'turnstileSolved', 'Turnstile solve completed', data)
307
+
308
+ return token
309
+ } catch (error) {
310
+ // Report failure
311
+ const data: TurnstileSolveAnalytics = {
312
+ durationMs: ctx.performanceTracker.now() - startTime,
313
+ success: false,
314
+ errorType: classifyError(error),
315
+ errorMessage: error instanceof Error ? error.message : String(error),
316
+ }
317
+ ctx.onSolveCompleted?.(data)
318
+ ctx.getLogger?.().warn('sessions', 'turnstileSolved', 'Turnstile solve failed', data)
319
+
320
+ ctx.getLogger?.().error(error, {
321
+ tags: {
322
+ file: 'createTurnstileSolver.ts',
323
+ function: 'solve',
324
+ },
325
+ })
326
+ throw error
327
+ } finally {
328
+ // Clean up timeout
329
+ if (cleanupState.timeoutId) {
330
+ clearTimeout(cleanupState.timeoutId)
331
+ }
332
+
333
+ // Clean up widget if it was created
334
+ // widgetId only exists if turnstile was successfully loaded and rendered
335
+ if (cleanupState.widgetId) {
336
+ try {
337
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
338
+ if (window.turnstile) {
339
+ window.turnstile.remove(cleanupState.widgetId)
340
+ }
341
+ } catch {
342
+ // Ignore cleanup errors
343
+ }
344
+ }
345
+
346
+ // Clean up container
347
+ if (container.parentNode) {
348
+ container.parentNode.removeChild(container)
349
+ }
350
+ }
351
+ }
352
+
353
+ return { solve }
354
+ }
355
+
356
+ export { createTurnstileSolver }
357
+ export type { CreateTurnstileSolverContext, TurnstileSolveAnalytics }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Native stub for hashcash core functions.
3
+ *
4
+ * Mobile does not use this file - it uses native Nitro modules
5
+ * (hashcash-native package) which bypass the JS hashcash implementation entirely.
6
+ *
7
+ * @see packages/hashcash-native for the native implementation
8
+ */
9
+
10
+ import { NotImplementedError } from 'utilities/src/errors'
11
+
12
+ export type { HashcashChallenge, ProofResult } from '@l.x/sessions/src/challenge-solvers/hashcash/shared'
13
+ // Re-export shared types and platform-agnostic functions
14
+ export { checkDifficulty, formatHashcashString } from '@l.x/sessions/src/challenge-solvers/hashcash/shared'
15
+
16
+ import type { HashcashChallenge, ProofResult } from '@l.x/sessions/src/challenge-solvers/hashcash/shared'
17
+
18
+ export async function computeHash(_params: { subject: string; nonce: string; counter: number }): Promise<Uint8Array> {
19
+ throw new NotImplementedError('computeHash - mobile uses native Nitro modules')
20
+ }
21
+
22
+ export async function findProof(_params: {
23
+ challenge: HashcashChallenge
24
+ rangeStart?: number
25
+ rangeSize?: number
26
+ shouldStop?: () => boolean
27
+ batchSize?: number
28
+ }): Promise<ProofResult | null> {
29
+ throw new NotImplementedError('findProof - mobile uses native Nitro modules')
30
+ }
31
+
32
+ export async function verifyProof(_challenge: HashcashChallenge, _proofCounter: string): Promise<boolean> {
33
+ throw new NotImplementedError('verifyProof - mobile uses native Nitro modules')
34
+ }
@@ -0,0 +1,314 @@
1
+ import {
2
+ checkDifficulty,
3
+ computeHash,
4
+ findProof,
5
+ formatHashcashString,
6
+ type HashcashChallenge,
7
+ verifyProof,
8
+ } from '@l.x/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: 'Lx',
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: 'Lx',
91
+ nonce: nonceString,
92
+ counter: 123,
93
+ })
94
+
95
+ // The hash should be of the string "Lx:AQIDBA==:123"
96
+ expect(hash).toBeDefined()
97
+ expect(hash.length).toBe(32)
98
+
99
+ // Verify the expected string format
100
+ const expectedString = `Lx:${nonceString}:123`
101
+ expect(expectedString).toBe('Lx:AQIDBA==:123')
102
+ })
103
+
104
+ it('matches known SHA-256 test vector', async () => {
105
+ // computeHash("Lx:AQIDBA==:123") verified against @noble/hashes/webcrypto SHA-256
106
+ const hash = await computeHash({
107
+ subject: 'Lx',
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('Lx') // 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('Lx')
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
+ })