Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 169 additions & 0 deletions src/hooks/useTrackReferral.spec.ts
Original file line number Diff line number Diff line change
@@ -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<typeof fetch>
const mockUseCurrentConnectionData = useCurrentConnectionData as jest.MockedFunction<typeof useCurrentConnectionData>
const mockGenerateDeviceFingerprint = generateDeviceFingerprint as jest.MockedFunction<typeof generateDeviceFingerprint>

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<typeof useCurrentConnectionData>)
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<typeof useCurrentConnectionData>)
})

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')
})
})
})
})
15 changes: 12 additions & 3 deletions src/hooks/useTrackReferral.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -18,11 +19,19 @@ export const useTrackReferral = () => {
try {
const body = method === 'POST' ? JSON.stringify({ referrer }) : undefined

const deviceFingerprint = await generateDeviceFingerprint()

const headers: Record<string, string> = {
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
})
Expand Down
61 changes: 61 additions & 0 deletions src/shared/utils/deviceFingerprint.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
99 changes: 99 additions & 0 deletions src/shared/utils/deviceFingerprint.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string> {
try {
const components = [getCanvasFingerprint(), getWebGLInfo(), getScreenInfo(), getBrowserProperties()]

const raw = components.join('||')
return await hashString(raw)
} catch {
return ''
}
}
Loading