diff --git a/core/src/exchanges/gemini-titan/auth.ts b/core/src/exchanges/gemini-titan/auth.ts index 6546c134..bb96a1d0 100644 --- a/core/src/exchanges/gemini-titan/auth.ts +++ b/core/src/exchanges/gemini-titan/auth.ts @@ -63,11 +63,11 @@ export class GeminiAuth { /** * Build WebSocket handshake authentication headers. * - * WebSocket auth uses a time-based nonce (seconds since epoch) + * WebSocket auth uses a time-based nonce (milliseconds since epoch) * as the payload, not a JSON object. */ buildWsHeaders(): Record { - const nonce = Math.floor(Date.now() / 1000).toString(); + const nonce = Date.now().toString(); const b64Payload = Buffer.from(nonce).toString('base64'); const signature = crypto .createHmac('sha384', this.apiSecret) diff --git a/core/test/exchanges/gemini-titan-auth.test.ts b/core/test/exchanges/gemini-titan-auth.test.ts new file mode 100644 index 00000000..7a72b2f9 --- /dev/null +++ b/core/test/exchanges/gemini-titan-auth.test.ts @@ -0,0 +1,37 @@ +import crypto from 'crypto'; +import { GeminiAuth } from '../../src/exchanges/gemini-titan/auth'; + +// Milliseconds matter here: Gemini's WebSocket API requires the handshake +// nonce to be a Unix timestamp in milliseconds. +const FROZEN_NOW_MS = 1755878400000; // 2025-08-22T16:00:00.000Z + +describe('GeminiAuth WebSocket headers', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('uses a millisecond nonce for the WebSocket handshake', () => { + jest.spyOn(Date, 'now').mockReturnValue(FROZEN_NOW_MS); + const auth = new GeminiAuth({ apiKey: 'test-key', apiSecret: 'test-secret' }); + + const headers = auth.buildWsHeaders(); + + expect(headers['X-GEMINI-NONCE']).toBe(String(FROZEN_NOW_MS)); + }); + + it('signs the same millisecond nonce it sends', () => { + jest.spyOn(Date, 'now').mockReturnValue(FROZEN_NOW_MS); + const auth = new GeminiAuth({ apiKey: 'test-key', apiSecret: 'test-secret' }); + + const headers = auth.buildWsHeaders(); + + const payload = Buffer.from(headers['X-GEMINI-PAYLOAD'], 'base64').toString('utf8'); + expect(payload).toBe(String(FROZEN_NOW_MS)); + + const expectedSignature = crypto + .createHmac('sha384', 'test-secret') + .update(headers['X-GEMINI-PAYLOAD']) + .digest('hex'); + expect(headers['X-GEMINI-SIGNATURE']).toBe(expectedSignature); + }); +});