diff --git a/packages/monitor-v2/src/monitor-polymarket/MonitorProposalsOrderBook.ts b/packages/monitor-v2/src/monitor-polymarket/MonitorProposalsOrderBook.ts index 35deca585d..d720a605d8 100644 --- a/packages/monitor-v2/src/monitor-polymarket/MonitorProposalsOrderBook.ts +++ b/packages/monitor-v2/src/monitor-polymarket/MonitorProposalsOrderBook.ts @@ -255,7 +255,7 @@ export async function monitorTransactionsProposedOrderBook( const questionID = calculatePolymarketQuestionID(proposal.ancillaryData); try { - const markets = await getPolymarketMarketInformation(logger, params, questionID); + const markets = await getPolymarketMarketInformation(logger, params, questionID, proposal.requester); markets.forEach((market) => { tokenIds.add(market.clobTokenIds[0]); tokenIds.add(market.clobTokenIds[1]); diff --git a/packages/monitor-v2/src/monitor-polymarket/common.ts b/packages/monitor-v2/src/monitor-polymarket/common.ts index 3b1481f79e..b652e308c8 100644 --- a/packages/monitor-v2/src/monitor-polymarket/common.ts +++ b/packages/monitor-v2/src/monitor-polymarket/common.ts @@ -36,6 +36,15 @@ export { Logger }; export const ONE_SCALED = ethers.utils.parseUnits("1", 18); export const POLYGON_BLOCKS_PER_HOUR = 1800; +const NEG_RISK_OPERATOR_ADDRESSES = [ + "0x71523d0f655B41E805Cec45b17163f528B59B820", + "0x661992aebf6BecF7BA5abB66f6b0Bf62Aa7a2E93", +]; +const LEGACY_NEG_RISK_ADAPTER = "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296"; +const NEG_RISK_OPERATOR_ABI = [ + "function questionIds(bytes32) view returns (bytes32)", + "function nrAdapter() view returns (address)", +]; /** * Determines if a trade represents a discrepancy based on token role and thresholds. @@ -71,18 +80,13 @@ const getPolymarketInitializerWhitelist = (): string[] => { return []; }; -interface GraphQLResponse { - data?: T; - errors?: { message: string }[]; -} - export interface MonitoringParams { ctfExchangeAddress: string; ctfSportsOracleAddress: string; additionalRequesters: string[]; maxBlockLookBack: number; graphqlEndpoint: string; - polymarketApiKey: string; + polymarketApiKey?: string; apiEndpoint: string; provider: Provider; chainId: number; @@ -104,13 +108,25 @@ export interface MonitoringParams { maxTradesPerToken: number; fillEventsChunkBlocks: number; } -interface PolymarketMarketGraphql { +interface PolymarketMarketResponse { question: string; outcomes: string; outcomePrices: string; - volumeNum: number; + volumeNum: number | string; clobTokenIds: string; questionID: string; + events?: { id?: string | number | null }[]; +} + +interface ClobMarketResponse { + market_slug?: string; + market_id?: string | number | null; + id?: string | number | null; + error?: string; +} + +interface GammaEventResponse { + markets?: PolymarketMarketResponse[]; } export interface PolymarketMarketGraphqlProcessed { @@ -353,51 +369,157 @@ export const shouldIgnoreThirdPartyProposal = async ( export const getPolymarketMarketInformation = async ( logger: typeof Logger, params: MonitoringParams, - questionID: string + questionID: string, + requesterAddress?: string ): Promise => { - // Gamma currently rejects LOWER(...) on these fields, so query with the exact hash we computed. - const query = ` - { - markets(where: "question_id = '${questionID}' or neg_risk_request_id = '${questionID}' or game_id = '${questionID}'") { - clobTokenIds - volumeNum - outcomes - outcomePrices - question - questionID + const isSportsRequester = + requesterAddress != null && requesterAddress.toLowerCase() === params.ctfSportsOracleAddress.toLowerCase(); + + const processMarket = (market: PolymarketMarketResponse): PolymarketMarketGraphqlProcessed => { + return { + ...market, + volumeNum: Number(market.volumeNum), + outcomes: JSON.parse(market.outcomes), + outcomePrices: JSON.parse(market.outcomePrices), + clobTokenIds: JSON.parse(market.clobTokenIds), + }; + }; + + const isProcessableMarket = ( + market: Partial | undefined + ): market is PolymarketMarketResponse => + market != null && + typeof market.question === "string" && + typeof market.outcomes === "string" && + typeof market.outcomePrices === "string" && + (typeof market.volumeNum === "number" || typeof market.volumeNum === "string") && + typeof market.clobTokenIds === "string" && + typeof market.questionID === "string"; + + const gammaApiBaseUrl = params.graphqlEndpoint.replace(/\/query\/?$/, ""); + + const getConditionId = (adapter: string, targetQuestionId: string): string => + ethers.utils.solidityKeccak256(["address", "bytes32", "uint256"], [adapter, targetQuestionId, 2]); + + const getStandardConditionIds = (targetQuestionId: string): string[] => { + if (!requesterAddress) return []; + + try { + return [getConditionId(ethers.utils.getAddress(requesterAddress), targetQuestionId)]; + } catch { + return []; + } + }; + + const getNegRiskConditionIds = async (): Promise => { + for (const operatorAddress of NEG_RISK_OPERATOR_ADDRESSES) { + try { + const operator = new ethers.Contract(operatorAddress, NEG_RISK_OPERATOR_ABI, params.provider); + const negRiskQuestionId: string = await operator.questionIds(questionID); + + if (!negRiskQuestionId || negRiskQuestionId === ethers.constants.HashZero) continue; + + const adapterCandidates = new Set([LEGACY_NEG_RISK_ADAPTER]); + try { + adapterCandidates.add(await operator.nrAdapter()); + } catch { + // Ignore adapter lookup failures and keep the legacy fallback. + } + + return [...adapterCandidates].reduce((conditionIds, address) => { + try { + conditionIds.push(getConditionId(ethers.utils.getAddress(address), negRiskQuestionId)); + } catch { + // Ignore malformed adapter addresses and continue. + } + return conditionIds; + }, []); + } catch { + // Ignore operator lookup failures and try the next operator. } } - `; - const { data } = await params.httpClient.post>( - params.graphqlEndpoint, - { query }, - { - headers: { authorization: `Bearer ${params.polymarketApiKey}` }, + + return []; + }; + + const findClobMarketForConditionIds = async (conditionIds: string[]): Promise => { + for (const conditionId of conditionIds) { + try { + const { data } = await params.httpClient.get( + `${params.apiEndpoint}/markets/${conditionId}` + ); + if (data && !data.error) return data; + } catch (error) { + const axiosError = error as AxiosError<{ error?: string }>; + if (axiosError.response?.status === 404) continue; + throw error; + } } - ); - if (data.errors?.length) { - throw new Error(data.errors.map((e) => e.message).join("; ")); - } + return null; + }; - if (!data.data?.markets) { - throw new Error("No markets found"); - } + const findClobMarket = async (): Promise => { + const standardClobMarket = await findClobMarketForConditionIds(getStandardConditionIds(questionID)); + if (standardClobMarket) return standardClobMarket; + + return findClobMarketForConditionIds(await getNegRiskConditionIds()); + }; - const { markets } = data.data; + const fetchGammaMarket = async (clobMarket: ClobMarketResponse): Promise => { + const candidateUrls = [ + clobMarket.market_slug ? `${gammaApiBaseUrl}/markets/slug/${encodeURIComponent(clobMarket.market_slug)}` : null, + clobMarket.market_id != null ? `${gammaApiBaseUrl}/markets/${clobMarket.market_id}` : null, + clobMarket.id != null ? `${gammaApiBaseUrl}/markets/${clobMarket.id}` : null, + ].filter((url): url is string => Boolean(url)); + + for (const url of candidateUrls) { + try { + const { data } = await params.httpClient.get(url); + const market = Array.isArray(data) ? data[0] : data; + if (market) return market; + } catch (error) { + const axiosError = error as AxiosError<{ error?: string }>; + if (axiosError.response?.status === 404) continue; + throw error; + } + } + + throw new Error(`No Gamma market found for question ID: ${questionID}`); + }; + + const fetchGammaEventMarkets = async (eventId: string | number): Promise => { + const { data } = await params.httpClient.get( + `${gammaApiBaseUrl}/events/${encodeURIComponent(String(eventId))}` + ); - if (!markets.length) { - throw new Error(`No market found for question ID: ${questionID}`); + const markets = (data.markets ?? []).filter(isProcessableMarket); + if (!markets.length) { + throw new Error(`No Gamma event markets found for question ID: ${questionID}`); + } + + return [...new Map(markets.map((market) => [market.questionID.toLowerCase(), market])).values()]; + }; + + const clobMarket = await findClobMarket(); + if (!clobMarket) throw new Error(`No market found for question ID: ${questionID}`); + + const primaryMarket = await fetchGammaMarket(clobMarket); + if (!isSportsRequester) return [processMarket(primaryMarket)]; + + const eventId = primaryMarket.events?.find((event) => event.id != null)?.id; + if (eventId == null) { + logger.warn({ + at: "PolymarketMonitor", + message: "Sports market resolved without Gamma event context; falling back to the primary market only", + questionID, + requesterAddress, + marketQuestionID: primaryMarket.questionID, + }); + return [processMarket(primaryMarket)]; } - return markets.map((market) => { - return { - ...market, - outcomes: JSON.parse(market.outcomes), - outcomePrices: JSON.parse(market.outcomePrices), - clobTokenIds: JSON.parse(market.clobTokenIds), - }; - }); + return (await fetchGammaEventMarkets(eventId)).map(processMarket); }; /** @@ -883,7 +1005,6 @@ export const initMonitoringParams = async ( if (!env.CHAIN_ID) throw new Error("CHAIN_ID must be defined in env"); const chainId = Number(env.CHAIN_ID); - if (!env.POLYMARKET_API_KEY) throw new Error("POLYMARKET_API_KEY must be defined in env"); const polymarketApiKey = env.POLYMARKET_API_KEY; if (!env.AI_RESULTS_BASE_URL) throw new Error("AI_RESULTS_BASE_URL must be defined in env"); diff --git a/packages/monitor-v2/test/PolymarketMonitor.ts b/packages/monitor-v2/test/PolymarketMonitor.ts index 828ad822f9..056572bcae 100644 --- a/packages/monitor-v2/test/PolymarketMonitor.ts +++ b/packages/monitor-v2/test/PolymarketMonitor.ts @@ -11,6 +11,7 @@ import { import { createNewLogger, spyLogIncludes, spyLogLevel, SpyTransport } from "@uma/financial-templates-lib"; import { createHttpClient } from "@uma/toolkit"; import { assert } from "chai"; +import { ethers as ethersLib } from "ethers"; import sinon from "sinon"; import * as commonModule from "../src/monitor-polymarket/common"; import { @@ -196,6 +197,283 @@ describe("PolymarketNotifier", function () { sandbox.stub(commonModule, functionName).callsFake(mockDataFunction); } + it("resolves markets through CLOB condition lookup and Gamma slug lookup", async function () { + const params = await createMonitoringParams(); + params.apiEndpoint = "https://clob.polymarket.com"; + params.graphqlEndpoint = "https://gamma-api.polymarket.com/query"; + + const questionId = "0x6e0a8c466f66bc6f7d3f113d3dcf019035adf909fd6efcca0368c3bec245914b"; + const requesterAddress = "0x157ce2d672854c848c9b79c49a8cc6cc89176a49"; + const marketSlug = "mex-que-jua-2026-02-22-que"; + const conditionId = ethersLib.utils.solidityKeccak256( + ["address", "bytes32", "uint256"], + [requesterAddress, questionId, 2] + ); + const providerCallStub = sandbox.stub().rejects(new Error("neg-risk lookup should not run")); + params.provider = ({ _isProvider: true, call: providerCallStub } as unknown) as Provider; + + const postStub = sandbox.stub(params.httpClient, "post"); + const getStub = sandbox.stub(params.httpClient, "get").callsFake(async (url: string) => { + if (url === `${params.apiEndpoint}/markets/${conditionId}`) { + return { data: { market_slug: marketSlug } }; + } + + if (url === `https://gamma-api.polymarket.com/markets/slug/${marketSlug}`) { + return { + data: { + clobTokenIds: JSON.stringify(["0x1234", "0x1235"]), + volumeNum: 200_000, + outcomes: JSON.stringify(["Yes", "No"]), + outcomePrices: JSON.stringify(["0.0005", "0.9995"]), + question: "Will Querétaro FC win on 2026-02-22?", + questionID: questionId, + }, + }; + } + + throw { response: { status: 404 } }; + }); + + const markets = await commonModule.getPolymarketMarketInformation( + commonModule.Logger, + params, + questionId, + requesterAddress + ); + + assert.equal(markets[0].questionID, questionId); + assert.equal(markets[0].question, "Will Querétaro FC win on 2026-02-22?"); + assert.isTrue(providerCallStub.notCalled); + assert.isTrue(getStub.calledWith(`${params.apiEndpoint}/markets/${conditionId}`)); + assert.isTrue(getStub.calledWith(`https://gamma-api.polymarket.com/markets/slug/${marketSlug}`)); + assert.isTrue(postStub.notCalled); + }); + + it("continues Gamma fallback URLs after a 404 on the slug lookup", async function () { + const params = await createMonitoringParams(); + params.apiEndpoint = "https://clob.polymarket.com"; + params.graphqlEndpoint = "https://gamma-api.polymarket.com/query"; + + const questionId = "0x6e0a8c466f66bc6f7d3f113d3dcf019035adf909fd6efcca0368c3bec245914b"; + const requesterAddress = "0x157ce2d672854c848c9b79c49a8cc6cc89176a49"; + const marketSlug = "stale-market-slug"; + const marketId = "1272207"; + const conditionId = ethersLib.utils.solidityKeccak256( + ["address", "bytes32", "uint256"], + [requesterAddress, questionId, 2] + ); + + const postStub = sandbox.stub(params.httpClient, "post"); + const getStub = sandbox.stub(params.httpClient, "get").callsFake(async (url: string) => { + if (url === `${params.apiEndpoint}/markets/${conditionId}`) { + return { data: { market_slug: marketSlug, market_id: marketId } }; + } + + if (url === `https://gamma-api.polymarket.com/markets/slug/${marketSlug}`) { + throw { response: { status: 404 } }; + } + + if (url === `https://gamma-api.polymarket.com/markets/${marketId}`) { + return { + data: { + clobTokenIds: JSON.stringify(["0x1234", "0x1235"]), + volumeNum: 200_000, + outcomes: JSON.stringify(["Yes", "No"]), + outcomePrices: JSON.stringify(["0.0005", "0.9995"]), + question: "Will Querétaro FC win on 2026-02-22?", + questionID: questionId, + }, + }; + } + + throw { response: { status: 404 } }; + }); + + const markets = await commonModule.getPolymarketMarketInformation( + commonModule.Logger, + params, + questionId, + requesterAddress + ); + + assert.equal(markets[0].questionID, questionId); + assert.isTrue(getStub.calledWith(`https://gamma-api.polymarket.com/markets/slug/${marketSlug}`)); + assert.isTrue(getStub.calledWith(`https://gamma-api.polymarket.com/markets/${marketId}`)); + assert.isTrue(postStub.notCalled); + }); + + it("falls back through neg-risk operator resolution when the standard condition lookup misses", async function () { + const params = await createMonitoringParams(); + params.apiEndpoint = "https://clob.polymarket.com"; + params.graphqlEndpoint = "https://gamma-api.polymarket.com/query"; + + const requestId = "0x6e0a8c466f66bc6f7d3f113d3dcf019035adf909fd6efcca0368c3bec245914b"; + const canonicalQuestionId = "0x303df379b982e189dc6aea356cad409cd18b8bc634a95e318b5e7ef025ab4400"; + const requesterAddress = "0x157ce2d672854c848c9b79c49a8cc6cc89176a49"; + const operatorAddress = "0x71523d0f655B41E805Cec45b17163f528B59B820"; + const adapterAddress = "0x1111111111111111111111111111111111111111"; + const marketSlug = "mex-que-jua-2026-02-22-que"; + const operatorInterface = new ethersLib.utils.Interface([ + "function questionIds(bytes32) view returns (bytes32)", + "function nrAdapter() view returns (address)", + ]); + + const standardConditionId = ethersLib.utils.solidityKeccak256( + ["address", "bytes32", "uint256"], + [requesterAddress, requestId, 2] + ); + const negRiskConditionId = ethersLib.utils.solidityKeccak256( + ["address", "bytes32", "uint256"], + [adapterAddress, canonicalQuestionId, 2] + ); + + const providerCallStub = sandbox.stub().callsFake(async (tx: { to?: string; data?: string }) => { + if (tx.to?.toLowerCase() !== operatorAddress.toLowerCase()) throw new Error("unexpected operator"); + if (tx.data === operatorInterface.encodeFunctionData("questionIds", [requestId])) { + return operatorInterface.encodeFunctionResult("questionIds", [canonicalQuestionId]); + } + if (tx.data === operatorInterface.encodeFunctionData("nrAdapter")) { + return operatorInterface.encodeFunctionResult("nrAdapter", [adapterAddress]); + } + throw new Error("unexpected call data"); + }); + params.provider = ({ _isProvider: true, call: providerCallStub } as unknown) as Provider; + + const postStub = sandbox.stub(params.httpClient, "post"); + const getStub = sandbox.stub(params.httpClient, "get").callsFake(async (url: string) => { + if (url === `${params.apiEndpoint}/markets/${standardConditionId}`) { + throw { response: { status: 404 } }; + } + + if (url === `${params.apiEndpoint}/markets/${negRiskConditionId}`) { + return { data: { market_slug: marketSlug } }; + } + + if (url === `https://gamma-api.polymarket.com/markets/slug/${marketSlug}`) { + return { + data: { + clobTokenIds: JSON.stringify(["0x1234", "0x1235"]), + volumeNum: 200_000, + outcomes: JSON.stringify(["Yes", "No"]), + outcomePrices: JSON.stringify(["0.0005", "0.9995"]), + question: "Will Querétaro FC win on 2026-02-22?", + questionID: canonicalQuestionId, + }, + }; + } + + throw { response: { status: 404 } }; + }); + + const markets = await commonModule.getPolymarketMarketInformation( + commonModule.Logger, + params, + requestId, + requesterAddress + ); + + assert.equal(markets[0].questionID, canonicalQuestionId); + assert.isTrue(providerCallStub.called); + assert.isTrue(getStub.calledWith(`${params.apiEndpoint}/markets/${standardConditionId}`)); + assert.isTrue(getStub.calledWith(`${params.apiEndpoint}/markets/${negRiskConditionId}`)); + assert.isTrue(getStub.calledWith(`https://gamma-api.polymarket.com/markets/slug/${marketSlug}`)); + assert.isTrue(postStub.notCalled); + }); + + it("returns every sports market from the Gamma event after the canonical CLOB lookup succeeds", async function () { + const params = await createMonitoringParams(); + params.apiEndpoint = "https://clob.polymarket.com"; + params.graphqlEndpoint = "https://gamma-api.polymarket.com/query"; + + const questionId = "0x303df379b982e189dc6aea356cad409cd18b8bc634a95e318b5e7ef025ab4400"; + const requesterAddress = "0x157ce2d672854c848c9b79c49a8cc6cc89176a49"; + const marketSlug = "mex-que-jua-2026-02-22-que"; + const eventId = "189646"; + params.ctfSportsOracleAddress = requesterAddress; + + const conditionId = ethersLib.utils.solidityKeccak256( + ["address", "bytes32", "uint256"], + [requesterAddress, questionId, 2] + ); + + const postStub = sandbox.stub(params.httpClient, "post"); + const getStub = sandbox.stub(params.httpClient, "get").callsFake(async (url: string) => { + if (url === `${params.apiEndpoint}/markets/${conditionId}`) { + return { data: { market_slug: marketSlug } }; + } + + if (url === `https://gamma-api.polymarket.com/markets/slug/${marketSlug}`) { + return { + data: { + clobTokenIds: JSON.stringify(["0x1234", "0x1235"]), + volumeNum: 200_000, + outcomes: JSON.stringify(["Yes", "No"]), + outcomePrices: JSON.stringify(["0.0005", "0.9995"]), + question: "Will Querétaro FC win on 2026-02-22?", + questionID: questionId, + events: [{ id: eventId }], + }, + }; + } + + if (url === `https://gamma-api.polymarket.com/events/${eventId}`) { + return { + data: { + markets: [ + { + clobTokenIds: JSON.stringify(["0x1234", "0x1235"]), + volumeNum: 200_000, + outcomes: JSON.stringify(["Yes", "No"]), + outcomePrices: JSON.stringify(["0.0005", "0.9995"]), + question: "Will Querétaro FC win on 2026-02-22?", + questionID: "0x303df379b982e189dc6aea356cad409cd18b8bc634a95e318b5e7ef025ab4400", + }, + { + clobTokenIds: JSON.stringify(["0x2234", "0x2235"]), + volumeNum: 125_000, + outcomes: JSON.stringify(["Yes", "No"]), + outcomePrices: JSON.stringify(["0.5", "0.5"]), + question: "Will Querétaro FC vs. FC Juárez end in a draw?", + questionID: "0x303df379b982e189dc6aea356cad409cd18b8bc634a95e318b5e7ef025ab4401", + }, + { + clobTokenIds: JSON.stringify(["0x3234", "0x3235"]), + volumeNum: 150_000, + outcomes: JSON.stringify(["Yes", "No"]), + outcomePrices: JSON.stringify(["0.1", "0.9"]), + question: "Will FC Juárez win on 2026-02-22?", + questionID: "0x303df379b982e189dc6aea356cad409cd18b8bc634a95e318b5e7ef025ab4402", + }, + ], + }, + }; + } + + throw { response: { status: 404 } }; + }); + + const markets = await commonModule.getPolymarketMarketInformation( + commonModule.Logger, + params, + questionId, + requesterAddress + ); + + assert.equal(markets.length, 3); + assert.deepEqual( + markets.map((market) => market.questionID), + [ + "0x303df379b982e189dc6aea356cad409cd18b8bc634a95e318b5e7ef025ab4400", + "0x303df379b982e189dc6aea356cad409cd18b8bc634a95e318b5e7ef025ab4401", + "0x303df379b982e189dc6aea356cad409cd18b8bc634a95e318b5e7ef025ab4402", + ] + ); + assert.isTrue(getStub.calledWith(`${params.apiEndpoint}/markets/${conditionId}`)); + assert.isTrue(getStub.calledWith(`https://gamma-api.polymarket.com/markets/slug/${marketSlug}`)); + assert.isTrue(getStub.calledWith(`https://gamma-api.polymarket.com/events/${eventId}`)); + assert.isTrue(postStub.notCalled); + }); + it("It should notify if there are orders over the threshold", async function () { const params = await createMonitoringParams();