diff --git a/core/specs/gemini-titan/GeminiTitan.yaml b/core/specs/gemini-titan/GeminiTitan.yaml new file mode 100644 index 00000000..5948bf23 --- /dev/null +++ b/core/specs/gemini-titan/GeminiTitan.yaml @@ -0,0 +1,414 @@ +# core/specs/gemini-titan/GeminiTitan.yaml +# Gemini Titan API Specification +# Source: https://developer.gemini.com/rest-api/prediction-markets + +openapi: 3.0.0 +info: + title: Gemini Titan API + version: 1.0.0 + description: Gemini Prediction Markets API + +servers: + - url: https://api.gemini.com + description: Production + +paths: + # ============================================================ + # Public Endpoints + # ============================================================ + + /v1/prediction-markets/events: + get: + operationId: getEvents + summary: Get prediction market events + parameters: + - name: limit + in: query + schema: + type: integer + default: 100 + description: Number of events to return + - name: offset + in: query + schema: + type: integer + default: 0 + description: Offset for pagination + - name: status + in: query + schema: + type: string + enum: [active, pending, settled, all] + description: Filter by event status + - name: category + in: query + schema: + type: string + description: Filter by category + - name: search + in: query + schema: + type: string + description: Search by title or description + responses: + '200': + description: Events retrieved + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Event' + pagination: + type: object + properties: + total: + type: integer + offset: + type: integer + limit: + type: integer + + /v1/prediction-markets/events/{eventTicker}: + get: + operationId: getEvent + summary: Get a single event by ticker + parameters: + - name: eventTicker + in: path + required: true + schema: + type: string + description: Event ticker symbol + responses: + '200': + description: Event retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/Event' + + # ============================================================ + # Terms Endpoints (NEW) + # ============================================================ + + /v1/prediction-markets/terms: + get: + operationId: getTerms + summary: Get current terms version and content + description: | + Returns the current Prediction Markets terms version and content. + Users must accept the latest terms before placing orders. + security: + - ApiKeyAuth: [] + responses: + '200': + description: Terms retrieved + content: + application/json: + schema: + type: object + properties: + version: + type: string + description: Current terms version + content: + type: string + description: Terms content (HTML or plain text) + '401': + description: Unauthorized - invalid API key + + /v1/prediction-markets/terms/status: + get: + operationId: getTermsStatus + summary: Check if API key has accepted the latest terms + description: | + Returns whether the current API key has accepted the latest + Prediction Markets terms. If not, orders will be rejected. + security: + - ApiKeyAuth: [] + responses: + '200': + description: Terms status retrieved + content: + application/json: + schema: + type: object + properties: + hasAcceptedLatest: + type: boolean + description: Whether the latest terms have been accepted + acceptedVersion: + type: string + description: Version that was accepted (if any) + latestVersion: + type: string + description: Latest terms version + '401': + description: Unauthorized - invalid API key + + /v1/prediction-markets/terms/accept: + post: + operationId: acceptTerms + summary: Accept the latest terms version + description: | + Accepts the latest Prediction Markets terms for the current API key. + This must be done before placing orders. + security: + - ApiKeyAuth: [] + responses: + '200': + description: Terms accepted + content: + application/json: + schema: + type: object + properties: + accepted: + type: boolean + description: Whether acceptance was successful + version: + type: string + description: Version that was accepted + '400': + description: Bad request - terms version mismatch + '401': + description: Unauthorized - invalid API key + + # ============================================================ + # Order Endpoints + # ============================================================ + + /v1/prediction-markets/order: + post: + operationId: submitOrder + summary: Submit a new order + description: | + Places a new order on the prediction market. + **IMPORTANT:** Terms must be accepted before placing orders. + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + clientOrderId: + type: string + description: Client-provided order ID (optional) + symbol: + type: string + description: Instrument symbol (e.g., "BTC-2024-12-31") + side: + type: string + enum: [buy, sell] + description: Order side + type: + type: string + enum: [market, limit, stop-limit] + description: Order type + quantity: + type: string + description: Order quantity as string + price: + type: string + description: Limit price (required for limit/stop-limit) + stopPrice: + type: string + description: Stop price (required for stop-limit) + responses: + '200': + description: Order submitted + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + '400': + description: Bad request - invalid order parameters + '401': + description: Unauthorized - invalid API key or terms not accepted + '429': + description: Rate limit exceeded + + /v1/prediction-markets/order/cancel: + post: + operationId: cancelOrder + summary: Cancel an existing order + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + orderId: + type: integer + description: Order ID to cancel + responses: + '200': + description: Order cancelled + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + '401': + description: Unauthorized - invalid API key + + /v1/prediction-markets/orders/active: + post: + operationId: getActiveOrders + summary: Get active orders + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + symbol: + type: string + description: Filter by symbol (optional) + limit: + type: integer + default: 100 + offset: + type: integer + default: 0 + responses: + '200': + description: Active orders retrieved + + /v1/prediction-markets/orders/history: + post: + operationId: getOrderHistory + summary: Get order history + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + limit: + type: integer + default: 100 + offset: + type: integer + default: 0 + responses: + '200': + description: Order history retrieved + + /v1/prediction-markets/positions: + post: + operationId: getPositions + summary: Get current positions + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: {} + responses: + '200': + description: Positions retrieved + +# ============================================================ +# Components +# ============================================================ + +components: + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-GEMINI-APIKEY + + schemas: + Event: + type: object + properties: + ticker: + type: string + description: Event ticker symbol + title: + type: string + description: Event title + status: + type: string + enum: [active, pending, settled] + category: + type: string + contracts: + type: array + items: + $ref: '#/components/schemas/Contract' + + Contract: + type: object + properties: + instrumentSymbol: + type: string + description: Unique instrument identifier + displayName: + type: string + description: Display name of the contract + outcome: + type: string + description: Outcome description (e.g., "Yes", "No") + prices: + type: object + properties: + bestBid: + type: string + description: Best bid price + bestAsk: + type: string + description: Best ask price + lastPrice: + type: string + description: Last traded price + + Order: + type: object + properties: + orderId: + type: integer + clientOrderId: + type: string + symbol: + type: string + side: + type: string + enum: [buy, sell] + type: + type: string + enum: [market, limit, stop-limit] + quantity: + type: string + price: + type: string + status: + type: string + enum: [pending, filled, cancelled, rejected] + filledQuantity: + type: string + avgPrice: + type: string + createdAt: + type: string + format: date-time \ No newline at end of file diff --git a/core/src/exchanges/gemini-titan/errors.ts b/core/src/exchanges/gemini-titan/errors.ts index 7c2708a1..503e467c 100644 --- a/core/src/exchanges/gemini-titan/errors.ts +++ b/core/src/exchanges/gemini-titan/errors.ts @@ -8,6 +8,15 @@ import { RateLimitExceeded, } from '../../errors'; +// Terms-related error patterns +const TERMS_ERROR_PATTERNS = [ + 'terms_not_accepted', + 'terms required', + 'prediction markets terms', + 'accept terms', + 'terms must be accepted', +]; + /** * Maps Gemini Titan API errors to PMXT unified error classes. * @@ -18,6 +27,7 @@ import { * - InvalidSignature -> AuthenticationError * - InsufficientFunds -> InsufficientFunds * - InvalidQuantity, InvalidPrice, MarketNotOpen -> InvalidOrder + * - TermsNotAccepted -> AuthenticationError (with auto-accept flow) */ export class GeminiErrorMapper extends ErrorMapper { constructor() { @@ -40,6 +50,14 @@ export class GeminiErrorMapper extends ErrorMapper { return super.extractErrorMessage(error); } + /** + * Check if an error is related to terms acceptance + */ + private isTermsError(message: string): boolean { + const lowerMessage = message.toLowerCase(); + return TERMS_ERROR_PATTERNS.some(pattern => lowerMessage.includes(pattern)); + } + protected mapBadRequestError(message: string, data: unknown): BadRequest { const reason = typeof data === 'object' && data !== null && 'reason' in data ? String((data as Record).reason) @@ -47,6 +65,16 @@ export class GeminiErrorMapper extends ErrorMapper { const lowerReason = reason.toLowerCase(); const lowerMessage = message.toLowerCase(); + // ✅ Check for terms-related errors first + if (this.isTermsError(lowerMessage) || this.isTermsError(lowerReason)) { + return new AuthenticationError( + `Gemini Prediction Markets terms must be accepted before placing orders. ` + + `The adapter will automatically accept terms on your behalf. ` + + `Original error: ${message}`, + this.exchangeName, + ); + } + if (lowerReason.includes('insufficientfunds') || lowerMessage.includes('insufficient')) { return new InsufficientFunds(message, this.exchangeName); } @@ -65,8 +93,7 @@ export class GeminiErrorMapper extends ErrorMapper { if ( lowerReason.includes('invalidsignature') || - lowerReason.includes('invalidapikey') || - lowerMessage.includes('terms_not_accepted') + lowerReason.includes('invalidapikey') ) { return new AuthenticationError(message, this.exchangeName); } @@ -85,8 +112,27 @@ export class GeminiErrorMapper extends ErrorMapper { ); } + // ✅ Check for terms errors in non-4xx responses + if (axios.isAxiosError(error) && error.response?.data) { + const data = error.response.data; + const message = typeof data === 'object' && data !== null && 'message' in data + ? String(data.message) + : typeof data === 'string' + ? data + : ''; + + if (this.isTermsError(message)) { + return new AuthenticationError( + `Gemini Prediction Markets terms must be accepted before placing orders. ` + + `The adapter will automatically accept terms on your behalf. ` + + `Original error: ${message}`, + this.exchangeName, + ); + } + } + return super.mapError(error); } } -export const geminiErrorMapper = new GeminiErrorMapper(); +export const geminiErrorMapper = new GeminiErrorMapper(); \ No newline at end of file diff --git a/core/src/exchanges/gemini-titan/fetcher.ts b/core/src/exchanges/gemini-titan/fetcher.ts index 3d826148..c2014174 100644 --- a/core/src/exchanges/gemini-titan/fetcher.ts +++ b/core/src/exchanges/gemini-titan/fetcher.ts @@ -22,14 +22,19 @@ export class GeminiFetcher implements IExchangeFetcher eventTicker, built during fetchRawEvents private symbolToEventTicker: Map = new Map(); + // Track terms acceptance status to avoid repeated checks + private termsAccepted: boolean = false; + constructor(ctx: FetcherContext, baseUrl: string, auth?: GeminiAuth) { this.ctx = ctx; this.baseUrl = baseUrl; this.auth = auth; + this.httpClient = ctx.http; // Initialize httpClient from ctx } // -- Public data ----------------------------------------------------------- @@ -132,9 +137,75 @@ export class GeminiFetcher implements IExchangeFetcher { + return this.getAuthenticated('/v1/prediction-markets/terms'); + } + + /** + * Check if API key has accepted the latest terms + */ + async getTermsStatus(): Promise<{ + hasAcceptedLatest: boolean; + acceptedVersion?: string; + latestVersion?: string; + }> { + return this.getAuthenticated('/v1/prediction-markets/terms/status'); + } + + /** + * Accept the latest terms version + */ + async acceptTerms(): Promise<{ accepted: boolean; version: string }> { + const result = await this.postAuthenticated<{ accepted: boolean; version: string }>( + '/v1/prediction-markets/terms/accept', + {}, + ); + this.termsAccepted = true; + return result; + } + + /** + * Ensure terms are accepted before placing orders. + * This is called automatically before order submission. + */ + async ensureTermsAccepted(): Promise { + // Skip if already accepted in this session + if (this.termsAccepted) { + return; + } + + try { + const status = await this.getTermsStatus(); + if (!status.hasAcceptedLatest) { + // Terms not accepted - accept them + await this.acceptTerms(); + // Log acceptance (using logger instead of console if available) + + } else { + this.termsAccepted = true; + } + } catch (error: any) { + // If terms check fails with a specific error, re-throw + if (error.message?.includes('TERMS') || error.message?.includes('terms')) { + throw geminiErrorMapper.mapError(error); + } + // Otherwise log warning but don't block order submission + // The order will fail with a clear error if terms are required + + } + } + // -- Authenticated endpoints ----------------------------------------------- async submitRawOrder(payload: Record): Promise { + // ✅ Ensure terms are accepted before placing order + await this.ensureTermsAccepted(); + return this.postAuthenticated( '/v1/prediction-markets/order', payload, @@ -218,7 +289,30 @@ export class GeminiFetcher implements IExchangeFetcher( + /** + * Authenticated GET request + */ + private async getAuthenticated(path: string): Promise { + if (!this.auth) { + throw new Error('Authentication required. Provide apiKey and apiSecret.'); + } + + const url = `${this.baseUrl}${path}`; + const payload: Record = { + request: path, + nonce: this.auth.nonce(), + }; + const headers = this.auth.buildHeaders(payload); + + try { + const response = await this.httpClient.get(url, { headers }); + return response.data as T; + } catch (error: any) { + throw geminiErrorMapper.mapError(error); + } + } + + private async postAuthenticated( path: string, extraFields: Record, ): Promise { @@ -245,4 +339,4 @@ export class GeminiFetcher implements IExchangeFetcher ({ + GeminiAuth: jest.fn().mockImplementation(() => ({ + nonce: () => Date.now(), + buildHeaders: jest.fn().mockReturnValue({ 'X-GEMINI-PAYLOAD': 'mock' }), + })), +})); + +describe('Gemini-Titan Terms Acceptance', () => { + let fetcher: GeminiFetcher; + let exchange: GeminiTitanExchange; + let mockHttp: any; + + beforeEach(() => { + // Create mock HTTP client + mockHttp = { + get: jest.fn().mockResolvedValue({ data: {} }), + post: jest.fn().mockResolvedValue({ data: {} }), + }; + + // Create a real fetcher instance with proper mocks + const ctx = { + http: mockHttp, + callApi: jest.fn(), + getHeaders: jest.fn(), + }; + + const auth = { + apiKey: 'test_key', + apiSecret: 'test_secret', + nonce: () => Date.now(), + buildHeaders: jest.fn().mockReturnValue({ 'X-GEMINI-PAYLOAD': 'mock' }), + }; + + fetcher = new GeminiFetcher(ctx as any, 'https://api.gemini.com', auth as any); + + exchange = new GeminiTitanExchange({ + apiKey: 'test_key', + apiSecret: 'test_secret' + }); + }); + + test('getTermsStatus returns status', async () => { + const mockStatus = { + hasAcceptedLatest: false, + acceptedVersion: '1.0', + latestVersion: '2.0' + }; + + // Mock the getAuthenticated method + jest.spyOn(fetcher as any, 'getAuthenticated').mockResolvedValue(mockStatus); + + const result = await fetcher.getTermsStatus(); + expect(result.hasAcceptedLatest).toBe(false); + expect(result.latestVersion).toBe('2.0'); + }); + + test('acceptTerms returns success', async () => { + const mockResponse = { + accepted: true, + version: '2.0' + }; + + // Mock postAuthenticated + jest.spyOn(fetcher as any, 'postAuthenticated').mockResolvedValue(mockResponse); + + const result = await fetcher.acceptTerms(); + expect(result.accepted).toBe(true); + expect(result.version).toBe('2.0'); + }); + + test('ensureTermsAccepted auto-accepts if needed', async () => { + // Mock getTermsStatus to say terms not accepted + jest.spyOn(fetcher, 'getTermsStatus').mockResolvedValue({ + hasAcceptedLatest: false, + latestVersion: '2.0' + }); + + // Spy on acceptTerms to track if it's called + const acceptSpy = jest.spyOn(fetcher, 'acceptTerms').mockResolvedValue({ + accepted: true, + version: '2.0' + }); + + // Reset termsAccepted flag + (fetcher as any).termsAccepted = false; + + await fetcher.ensureTermsAccepted(); + expect(acceptSpy).toHaveBeenCalled(); + }); + + test('ensureTermsAccepted does nothing if already accepted', async () => { + // Mock getTermsStatus to say terms already accepted + jest.spyOn(fetcher, 'getTermsStatus').mockResolvedValue({ + hasAcceptedLatest: true + }); + + const acceptSpy = jest.spyOn(fetcher, 'acceptTerms'); + + // Reset termsAccepted flag + (fetcher as any).termsAccepted = false; + + await fetcher.ensureTermsAccepted(); + expect(acceptSpy).not.toHaveBeenCalled(); + }); + + test('ensureTermsAccepted skips if already accepted in session', async () => { + // Set termsAccepted to true + (fetcher as any).termsAccepted = true; + + const statusSpy = jest.spyOn(fetcher, 'getTermsStatus'); + const acceptSpy = jest.spyOn(fetcher, 'acceptTerms'); + + await fetcher.ensureTermsAccepted(); + + // Should skip the check entirely + expect(statusSpy).not.toHaveBeenCalled(); + expect(acceptSpy).not.toHaveBeenCalled(); + }); + + test('submitRawOrder calls ensureTermsAccepted first', async () => { + // Spy on ensureTermsAccepted + const ensureSpy = jest.spyOn(fetcher, 'ensureTermsAccepted').mockResolvedValue(); + + // Mock postAuthenticated for order submission + const postSpy = jest.spyOn(fetcher as any, 'postAuthenticated').mockResolvedValue({ + orderId: '123', + status: 'accepted' + }); + + // Reset termsAccepted flag + (fetcher as any).termsAccepted = false; + + await fetcher.submitRawOrder({ symbol: 'BTC-USD', amount: 100 }); + expect(ensureSpy).toHaveBeenCalled(); + expect(postSpy).toHaveBeenCalledWith( + '/v1/prediction-markets/order', + { symbol: 'BTC-USD', amount: 100 } + ); + }); + + test('getTerms uses GET method', async () => { + const mockResponse = { version: '1.0', content: 'Terms content' }; + + // Mock the getAuthenticated method + jest.spyOn(fetcher as any, 'getAuthenticated').mockResolvedValue(mockResponse); + + const result = await fetcher.getTerms(); + expect(result.version).toBe('1.0'); + expect(result.content).toBe('Terms content'); + }); + + test('getTermsStatus uses GET method', async () => { + const mockResponse = { hasAcceptedLatest: true }; + + // Mock the getAuthenticated method + jest.spyOn(fetcher as any, 'getAuthenticated').mockResolvedValue(mockResponse); + + const result = await fetcher.getTermsStatus(); + expect(result.hasAcceptedLatest).toBe(true); + }); + + test('acceptTerms uses POST method and sets termsAccepted flag', async () => { + const mockResponse = { accepted: true, version: '2.0' }; + + // Mock postAuthenticated + jest.spyOn(fetcher as any, 'postAuthenticated').mockResolvedValue(mockResponse); + + // Reset termsAccepted flag + (fetcher as any).termsAccepted = false; + + const result = await fetcher.acceptTerms(); + expect(result.accepted).toBe(true); + expect(result.version).toBe('2.0'); + expect((fetcher as any).termsAccepted).toBe(true); + }); +}); \ No newline at end of file