diff --git a/src/hooks/useTrackReferral.spec.ts b/src/hooks/useTrackReferral.spec.ts new file mode 100644 index 00000000..0b92f6ea --- /dev/null +++ b/src/hooks/useTrackReferral.spec.ts @@ -0,0 +1,169 @@ +import { renderHook, act } from '@testing-library/react' +import fetch from 'decentraland-crypto-fetch' +import { useCurrentConnectionData } from '../shared/connection' +import { generateDeviceFingerprint } from '../shared/utils/deviceFingerprint' +import { handleErrorWithContext } from '../shared/utils/errorHandler' +import { useTrackReferral } from './useTrackReferral' + +jest.mock('decentraland-crypto-fetch', () => jest.fn()) + +jest.mock('../modules/config', () => ({ + config: { get: jest.fn().mockReturnValue('https://mock-referral-server.com') } +})) + +jest.mock('../shared/connection', () => ({ + useCurrentConnectionData: jest.fn() +})) + +jest.mock('../shared/utils/deviceFingerprint', () => ({ + generateDeviceFingerprint: jest.fn() +})) + +jest.mock('../shared/utils/errorHandler', () => ({ + handleErrorWithContext: jest.fn() +})) + +const mockFetch = fetch as jest.MockedFunction +const mockUseCurrentConnectionData = useCurrentConnectionData as jest.MockedFunction +const mockGenerateDeviceFingerprint = generateDeviceFingerprint as jest.MockedFunction + +describe('when using the useTrackReferral hook', () => { + const mockIdentity = { + ephemeralIdentity: { privateKey: 'pk', publicKey: 'pub', address: '0xuser' }, + expiration: new Date(Date.now() + 3600000), + authChain: [] + } + + afterEach(() => { + jest.clearAllMocks() + }) + + describe('when the identity is available', () => { + beforeEach(() => { + mockUseCurrentConnectionData.mockReturnValue({ + identity: mockIdentity, + account: '0xuser', + isLoading: false, + provider: undefined, + providerType: undefined, + chainId: undefined, + getIdentitySignature: jest.fn() + } as ReturnType) + mockFetch.mockResolvedValue({} as Response) + mockGenerateDeviceFingerprint.mockResolvedValue('abc123fingerprint') + }) + + it('should report isReady as true', () => { + const { result } = renderHook(() => useTrackReferral()) + expect(result.current.isReady).toBe(true) + }) + + describe('when tracking a referral with POST', () => { + it('should send the device fingerprint header', async () => { + const { result } = renderHook(() => useTrackReferral()) + + await act(async () => { + await result.current.track('0xreferrer', 'POST') + }) + + expect(mockFetch).toHaveBeenCalledWith( + 'https://mock-referral-server.com/referral-progress', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'x-device-fingerprint': 'abc123fingerprint' + }), + body: JSON.stringify({ referrer: '0xreferrer' }), + identity: mockIdentity + }) + ) + }) + }) + + describe('when tracking a referral with PATCH', () => { + it('should send the device fingerprint header', async () => { + const { result } = renderHook(() => useTrackReferral()) + + await act(async () => { + await result.current.track('0xreferrer', 'PATCH') + }) + + expect(mockFetch).toHaveBeenCalledWith( + 'https://mock-referral-server.com/referral-progress', + expect.objectContaining({ + method: 'PATCH', + headers: expect.objectContaining({ + 'x-device-fingerprint': 'abc123fingerprint' + }), + identity: mockIdentity + }) + ) + }) + }) + + describe('when the device fingerprint is empty', () => { + beforeEach(() => { + mockGenerateDeviceFingerprint.mockResolvedValue('') + }) + + it('should omit the device fingerprint header', async () => { + const { result } = renderHook(() => useTrackReferral()) + + await act(async () => { + await result.current.track('0xreferrer', 'POST') + }) + + expect(mockFetch).toHaveBeenCalledWith( + 'https://mock-referral-server.com/referral-progress', + expect.objectContaining({ + headers: expect.not.objectContaining({ + 'x-device-fingerprint': expect.anything() + }) + }) + ) + }) + }) + + describe('when the fetch request fails', () => { + let error: Error + + beforeEach(() => { + error = new Error('Network error') + mockFetch.mockRejectedValue(error) + }) + + it('should handle the error with context and re-throw', async () => { + const { result } = renderHook(() => useTrackReferral()) + + await expect(result.current.track('0xreferrer')).rejects.toThrow('Network error') + expect(handleErrorWithContext).toHaveBeenCalledWith(error, 'Failed to track referral progress', expect.any(Object)) + }) + }) + }) + + describe('when the identity is not available', () => { + beforeEach(() => { + mockUseCurrentConnectionData.mockReturnValue({ + identity: null, + account: null, + isLoading: false, + provider: undefined, + providerType: undefined, + chainId: undefined, + getIdentitySignature: jest.fn() + } as unknown as ReturnType) + }) + + it('should report isReady as false', () => { + const { result } = renderHook(() => useTrackReferral()) + expect(result.current.isReady).toBe(false) + }) + + describe('when attempting to track a referral', () => { + it('should throw an error indicating no identity is available', async () => { + const { result } = renderHook(() => useTrackReferral()) + await expect(result.current.track('0xreferrer')).rejects.toThrow('No identity available for tracking referral') + }) + }) + }) +}) diff --git a/src/hooks/useTrackReferral.ts b/src/hooks/useTrackReferral.ts index 6a12f99a..58bea965 100644 --- a/src/hooks/useTrackReferral.ts +++ b/src/hooks/useTrackReferral.ts @@ -2,6 +2,7 @@ import { useCallback } from 'react' import fetch from 'decentraland-crypto-fetch' import { config } from '../modules/config' import { useCurrentConnectionData } from '../shared/connection' +import { generateDeviceFingerprint } from '../shared/utils/deviceFingerprint' import { handleErrorWithContext } from '../shared/utils/errorHandler' const REFERRAL_SERVER_URL = config.get('REFERRAL_SERVER_URL') @@ -18,11 +19,19 @@ export const useTrackReferral = () => { try { const body = method === 'POST' ? JSON.stringify({ referrer }) : undefined + const deviceFingerprint = await generateDeviceFingerprint() + + const headers: Record = { + contentType: 'application/json' + } + + if (deviceFingerprint) { + headers['x-device-fingerprint'] = deviceFingerprint + } + await fetch(`${REFERRAL_SERVER_URL}/referral-progress`, { method, - headers: { - contentType: 'application/json' - }, + headers, ...(body && { body }), identity }) diff --git a/src/shared/utils/deviceFingerprint.spec.ts b/src/shared/utils/deviceFingerprint.spec.ts new file mode 100644 index 00000000..1e9f1620 --- /dev/null +++ b/src/shared/utils/deviceFingerprint.spec.ts @@ -0,0 +1,61 @@ +import { generateDeviceFingerprint } from './deviceFingerprint' + +describe('when generating a device fingerprint', () => { + let mockDigest: jest.Mock + + beforeEach(() => { + mockDigest = jest.fn() + + Object.defineProperty(global, 'crypto', { + value: { + subtle: { + digest: mockDigest + } + }, + writable: true, + configurable: true + }) + + mockDigest.mockResolvedValue(new Uint8Array([0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78, 0x90]).buffer) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + describe('when all browser components are available', () => { + it('should return a hex string hashed with SHA-256', async () => { + const fingerprint = await generateDeviceFingerprint() + expect(fingerprint).toBe('abcdef1234567890') + expect(mockDigest).toHaveBeenCalledTimes(1) + expect(mockDigest.mock.calls[0][0]).toBe('SHA-256') + }) + }) + + describe('when canvas and WebGL are unavailable', () => { + it('should return a non-empty string using remaining browser properties', async () => { + const fingerprint = await generateDeviceFingerprint() + expect(typeof fingerprint).toBe('string') + expect(fingerprint.length).toBeGreaterThan(0) + }) + }) + + describe('when the hashing fails completely', () => { + beforeEach(() => { + mockDigest.mockRejectedValue(new Error('crypto not available')) + }) + + it('should return an empty string', async () => { + const fingerprint = await generateDeviceFingerprint() + expect(fingerprint).toBe('') + }) + }) + + describe('when called multiple times in the same environment', () => { + it('should produce consistent results', async () => { + const fp1 = await generateDeviceFingerprint() + const fp2 = await generateDeviceFingerprint() + expect(fp1).toBe(fp2) + }) + }) +}) diff --git a/src/shared/utils/deviceFingerprint.ts b/src/shared/utils/deviceFingerprint.ts new file mode 100644 index 00000000..4aeb7fb3 --- /dev/null +++ b/src/shared/utils/deviceFingerprint.ts @@ -0,0 +1,99 @@ +/** + * Generates a lightweight device fingerprint based on browser characteristics. + * This is used as an anti-abuse signal for the referral system — it helps detect + * users rotating VPNs/proxies while using the same physical device. + * + * The fingerprint is a hex-encoded hash of stable browser properties: + * - Canvas rendering output + * - WebGL renderer info + * - Screen dimensions + * - Timezone + * - Language and platform + * - Hardware concurrency and device memory + * + * This does NOT use cookies, local storage, or any persistent state. + * It is computed fresh on each call and sent as a request header. + */ + +async function hashString(input: string): Promise { + const encoder = new TextEncoder() + const data = encoder.encode(input) + const hashBuffer = await crypto.subtle.digest('SHA-256', data) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') +} + +function getCanvasFingerprint(): string { + try { + const canvas = document.createElement('canvas') + canvas.width = 200 + canvas.height = 50 + const ctx = canvas.getContext('2d') + if (!ctx) return '' + + ctx.textBaseline = 'top' + ctx.font = '14px Arial' + ctx.fillStyle = '#f60' + ctx.fillRect(125, 1, 62, 20) + ctx.fillStyle = '#069' + ctx.fillText('Dcl fingerprint', 2, 15) + ctx.fillStyle = 'rgba(102, 204, 0, 0.7)' + ctx.fillText('Dcl fingerprint', 4, 17) + + return canvas.toDataURL() + } catch { + return '' + } +} + +function getWebGLInfo(): string { + try { + const canvas = document.createElement('canvas') + const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl') + if (!gl || !(gl instanceof WebGLRenderingContext)) return '' + + const debugInfo = gl.getExtension('WEBGL_debug_renderer_info') + if (!debugInfo) return '' + + const vendor = gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL) as string + const renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) as string + return `${vendor}~${renderer}` + } catch { + return '' + } +} + +function getScreenInfo(): string { + try { + return `${screen.width}x${screen.height}x${screen.colorDepth}x${window.devicePixelRatio}` + } catch { + return '' + } +} + +function getBrowserProperties(): string { + const props = [ + navigator.language, + navigator.platform, + navigator.hardwareConcurrency?.toString() ?? '', + (navigator as Navigator & { deviceMemory?: number }).deviceMemory?.toString() ?? '', + Intl.DateTimeFormat().resolvedOptions().timeZone, + new Date().getTimezoneOffset().toString() + ] + return props.join('|') +} + +/** + * Generates a device fingerprint string (SHA-256 hex hash). + * Returns an empty string if fingerprinting fails entirely. + */ +export async function generateDeviceFingerprint(): Promise { + try { + const components = [getCanvasFingerprint(), getWebGLInfo(), getScreenInfo(), getBrowserProperties()] + + const raw = components.join('||') + return await hashString(raw) + } catch { + return '' + } +}