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
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
209 changes: 165 additions & 44 deletions packages/monitor-v2/src/monitor-polymarket/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -71,18 +80,13 @@ const getPolymarketInitializerWhitelist = (): string[] => {
return [];
};

interface GraphQLResponse<T> {
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;
Expand All @@ -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 {
Expand Down Expand Up @@ -353,51 +369,157 @@ export const shouldIgnoreThirdPartyProposal = async (
export const getPolymarketMarketInformation = async (
logger: typeof Logger,
params: MonitoringParams,
questionID: string
questionID: string,
requesterAddress?: string
): Promise<PolymarketMarketGraphqlProcessed[]> => {
// 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<PolymarketMarketResponse> | 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\/?$/, "");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This lookup path is now entirely public REST. Should initMonitoringParams() still hard-require POLYMARKET_API_KEY? Otherwise the monitor can still fail to start without a secret this change no longer uses.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call, fixed


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<string[]> => {
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<string>([LEGACY_NEG_RISK_ADAPTER]);
try {
adapterCandidates.add(await operator.nrAdapter());
} catch {
// Ignore adapter lookup failures and keep the legacy fallback.
}

return [...adapterCandidates].reduce<string[]>((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<GraphQLResponse<{ markets: PolymarketMarketGraphql[] }>>(
params.graphqlEndpoint,
{ query },
{
headers: { authorization: `Bearer ${params.polymarketApiKey}` },

return [];
};

const findClobMarketForConditionIds = async (conditionIds: string[]): Promise<ClobMarketResponse | null> => {
for (const conditionId of conditionIds) {
try {
const { data } = await params.httpClient.get<ClobMarketResponse>(
`${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<ClobMarketResponse | null> => {
const standardClobMarket = await findClobMarketForConditionIds(getStandardConditionIds(questionID));
if (standardClobMarket) return standardClobMarket;

return findClobMarketForConditionIds(await getNegRiskConditionIds());
};

const { markets } = data.data;
const fetchGammaMarket = async (clobMarket: ClobMarketResponse): Promise<PolymarketMarketResponse> => {
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<PolymarketMarketResponse | PolymarketMarketResponse[]>(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<PolymarketMarketResponse[]> => {
const { data } = await params.httpClient.get<GammaEventResponse>(
`${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);
};

/**
Expand Down Expand Up @@ -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");
Expand Down
Loading