From 85fc0e047af26ac260afec7ff632cbd450a9fd05 Mon Sep 17 00:00:00 2001 From: Pablo Maldonado Date: Mon, 1 Jun 2026 15:06:53 +0000 Subject: [PATCH] feat(bot-oo): add include/exclude proposal lists to OOv2 settler Adds SETTLE_INCLUDE_LIST and SETTLE_EXCLUDE_LIST env vars to the unified OO settlement bot, scoped to OptimisticOracleV2. Proposals are matched by their ProposePrice event identifier (:). When an include list is set the bot settles only those proposals (takes precedence over the exclude list); otherwise proposals in the exclude list are skipped. --- packages/monitor-v2/src/bot-oo/README.md | 2 + .../src/bot-oo/SettleOOv2Requests.ts | 39 ++++++++- packages/monitor-v2/src/bot-oo/common.ts | 38 +++++++++ packages/monitor-v2/src/bot-oo/requestKey.ts | 5 ++ .../monitor-v2/test/OptimisticOracleV2Bot.ts | 80 +++++++++++++++++++ 5 files changed, 162 insertions(+), 2 deletions(-) diff --git a/packages/monitor-v2/src/bot-oo/README.md b/packages/monitor-v2/src/bot-oo/README.md index 9a655f7616..38d4a13f56 100644 --- a/packages/monitor-v2/src/bot-oo/README.md +++ b/packages/monitor-v2/src/bot-oo/README.md @@ -36,6 +36,8 @@ node ./packages/monitor-v2/dist/bot-oo/index.js - `SETTLE_MIN_PROPOSAL_AGE_SECONDS`: Minimum proposal age in seconds before settling OOv2 requests (default `8100`, set `0` to disable). - `SETTLE_TIMEOUT`: Timeout in seconds for submitting settlement transactions in serverless mode (default `240`). - `SETTLE_ONLY_DISPUTED`: When `true`, only settle requests that have been disputed (`false` by default). Supported for `OptimisticOracleV2` (including `ManagedOptimisticOracleV2`); ignored for `OptimisticOracle` and `SkinnyOptimisticOracle`. +- `SETTLE_INCLUDE_LIST`: JSON array of `":"` proposal identifiers (the transaction hash and log index of the `ProposePrice` event). When set, the bot settles **only** these proposals. Takes precedence over `SETTLE_EXCLUDE_LIST`. `OptimisticOracleV2` only. Example: `["0xabc...def:5"]`. +- `SETTLE_EXCLUDE_LIST`: JSON array of `":"` proposal identifiers to **skip**. Ignored when `SETTLE_INCLUDE_LIST` is set. `OptimisticOracleV2` only. ## Behavior diff --git a/packages/monitor-v2/src/bot-oo/SettleOOv2Requests.ts b/packages/monitor-v2/src/bot-oo/SettleOOv2Requests.ts index 8458dc1688..dd4da88946 100644 --- a/packages/monitor-v2/src/bot-oo/SettleOOv2Requests.ts +++ b/packages/monitor-v2/src/bot-oo/SettleOOv2Requests.ts @@ -7,7 +7,7 @@ import { ethers } from "ethers"; import { computeEventSearch } from "../bot-utils/events"; import { logSettleRequest } from "./BotLogger"; import { getContractInstanceWithProvider, Logger, MonitoringParams, OptimisticOracleV2Ethers } from "./common"; -import { requestKey } from "./requestKey"; +import { proposalEventId, requestKey } from "./requestKey"; import type { GasEstimator } from "@uma/financial-templates-lib"; import { getSettleTxErrorLogFields, getSettleTxErrorLogLevel } from "../bot-utils/errors"; @@ -21,6 +21,39 @@ function chunk(arr: T[], size: number): T[][] { return chunks; } +// Applies the include/exclude proposal lists. The include list is exclusive and takes precedence: when set, only its +// proposals are settled. Otherwise proposals in the exclude list are skipped. Proposals are matched by the +// transaction hash and log index of their ProposePrice event. +function filterByIncludeExclude( + logger: typeof Logger, + params: MonitoringParams, + requests: ProposePriceEvent[] +): ProposePriceEvent[] { + const { settleIncludeList, settleExcludeList } = params; + if (!settleIncludeList && !settleExcludeList) return requests; + + const kept: ProposePriceEvent[] = []; + const skipped: string[] = []; + for (const req of requests) { + const id = proposalEventId(req.transactionHash, req.logIndex); + const allowed = settleIncludeList ? settleIncludeList.has(id) : !settleExcludeList?.has(id); + if (allowed) kept.push(req); + else skipped.push(id); + } + + logger.debug({ + at: "OOv2Bot", + message: "Applied include/exclude proposal filter", + mode: settleIncludeList ? "include" : "exclude", + listSize: (settleIncludeList ?? settleExcludeList)?.size, + kept: kept.length, + skipped: skipped.length, + skippedIds: skipped, + }); + + return kept; +} + export async function settleOOv2Requests( logger: typeof Logger, params: MonitoringParams, @@ -99,7 +132,9 @@ export async function settleOOv2Requests( const settledKeys = new Set(settlements.map((e) => requestKey(e.args))); - const requestsToSettle = proposals.filter((e) => !settledKeys.has(requestKey(e.args))); + const unsettledRequests = proposals.filter((e) => !settledKeys.has(requestKey(e.args))); + + const requestsToSettle = filterByIncludeExclude(logger, params, unsettledRequests); const requestsToSettleTxCount = params.settleBatchSize > 1 ? Math.ceil(requestsToSettle.length / params.settleBatchSize) : requestsToSettle.length; diff --git a/packages/monitor-v2/src/bot-oo/common.ts b/packages/monitor-v2/src/bot-oo/common.ts index faf1ca56d2..7f3f6f61db 100644 --- a/packages/monitor-v2/src/bot-oo/common.ts +++ b/packages/monitor-v2/src/bot-oo/common.ts @@ -3,17 +3,45 @@ export { Logger } from "@uma/financial-templates-lib"; export { computeEventSearch } from "../bot-utils/events"; export { getContractInstanceWithProvider } from "../utils/contracts"; import { BaseMonitoringParams, startupLogLevel as baseStartup, initBaseMonitoringParams } from "../bot-utils/base"; +import { proposalEventId } from "./requestKey"; export type OracleType = "OptimisticOracle" | "SkinnyOptimisticOracle" | "OptimisticOracleV2"; const DEFAULT_SETTLE_MIN_PROPOSAL_AGE_SECONDS = 2 * 60 * 60 + 15 * 60; +const PROPOSAL_ID_REGEX = /^(0x[0-9a-fA-F]{64}):(\d+)$/; + function getNonNegativeNumber(value: string | undefined, defaultValue: number): number { if (value === undefined) return defaultValue; const parsed = Number(value); return Number.isFinite(parsed) ? Math.max(0, parsed) : defaultValue; } +// Parses a JSON array of ":" strings into a normalized set of proposal event ids. +// Returns undefined when the env var is unset/empty so the bot keeps its default (settle everything) behavior. +function parseProposalIdList(value: string | undefined, envName: string): Set | undefined { + if (value === undefined || value.trim() === "") return undefined; + + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new Error(`${envName} must be a JSON array of ":" strings`); + } + if (!Array.isArray(parsed) || parsed.length === 0) { + throw new Error(`${envName} must be a non-empty JSON array of ":" strings`); + } + + const ids = parsed.map((entry) => { + if (typeof entry !== "string") throw new Error(`${envName} entries must be ":" strings`); + const match = entry.match(PROPOSAL_ID_REGEX); + if (!match) throw new Error(`Invalid ${envName} entry "${entry}"; expected ":"`); + return proposalEventId(match[1], Number(match[2])); + }); + + return new Set(ids); +} + export interface BotModes { settleRequestsEnabled: boolean; settleOnlyDisputed: boolean; // Supported for OptimisticOracleV2 (incl. ManagedOOv2); ignored for OOv1 and SkinnyOO. @@ -27,6 +55,11 @@ export interface MonitoringParams extends BaseMonitoringParams { executionDeadline?: number; // Timestamp in sec for when to stop settling, defaults to 4 minutes from now in serverless settleBatchSize: number; // Number of settle calls to batch via multicall (requires MultiCaller on contract), defaults to 1 settleMinProposalAgeSeconds: number; // Minimum proposal age before settlement, defaults to 2h15m + // Include/exclude lists of proposal event ids (":"). OptimisticOracleV2 only. + // When settleIncludeList is set, only those proposals are settled (it takes precedence over the exclude list). + // Otherwise, proposals in settleExcludeList are skipped. Both undefined means settle everything (default). + settleIncludeList?: Set; + settleExcludeList?: Set; } export const initMonitoringParams = async (env: NodeJS.ProcessEnv): Promise => { @@ -65,6 +98,9 @@ export const initMonitoringParams = async (env: NodeJS.ProcessEnv): Promise [args.requester, args.identifier, args.timestamp, args.ancillaryData] ) ); + +// Identifies a proposal by the transaction hash and log index of its ProposePrice event. This is the +// identifier used by the include/exclude settlement lists (matches how proposals are referenced in the explorer). +export const proposalEventId = (transactionHash: string, logIndex: number): string => + `${transactionHash.toLowerCase()}:${logIndex}`; diff --git a/packages/monitor-v2/test/OptimisticOracleV2Bot.ts b/packages/monitor-v2/test/OptimisticOracleV2Bot.ts index e65a7b7d24..8fb0b28afa 100644 --- a/packages/monitor-v2/test/OptimisticOracleV2Bot.ts +++ b/packages/monitor-v2/test/OptimisticOracleV2Bot.ts @@ -8,6 +8,7 @@ import { import { spyLogIncludes, spyLogLevel, GasEstimator } from "@uma/financial-templates-lib"; import { assert } from "chai"; import { OracleType } from "../src/bot-oo/common"; +import { proposalEventId } from "../src/bot-oo/requestKey"; import { settleRequests } from "../src/bot-oo/SettleRequests"; import { defaultLiveness, defaultOptimisticOracleV2Identifier } from "./constants"; import { optimisticOracleV2Fixture } from "./fixtures/OptimisticOracleV2.Fixture"; @@ -36,6 +37,12 @@ const getLast = (items: T[], message: string) => { return item; }; +const getProposalEventId = (receipt: { events?: { event?: string; transactionHash: string; logIndex: number }[] }) => { + const event = receipt.events?.find((e) => e.event === "ProposePrice"); + if (event === undefined) throw new Error("Expected a ProposePrice event in the receipt"); + return proposalEventId(event.transactionHash, event.logIndex); +}; + describe("OptimisticOracleV2Bot", function () { let bondToken: ExpandedERC20Ethers; let optimisticOracleV2: OptimisticOracleV2Ethers; @@ -473,4 +480,77 @@ describe("OptimisticOracleV2Bot", function () { .findIndex((c) => c.lastArg?.message === "Price Request Settled ✅" && c.lastArg?.at === "OOv2Bot"); assert.isAbove(settledIndex, -1, "Disputed request should be settled when settleOnlyDisputed is true"); }); + + it("Skips proposals in the exclude list", async function () { + await ( + await optimisticOracleV2.requestPrice(defaultOptimisticOracleV2Identifier, 0, ancillaryData, bondToken.address, 0) + ).wait(); + + const proposeReceipt = await ( + await optimisticOracleV2 + .connect(proposer) + .proposePrice( + await requester.getAddress(), + defaultOptimisticOracleV2Identifier, + 0, + ancillaryData, + ethers.utils.parseEther("1") + ) + ).wait(); + + await advanceTimerPastLiveness(timer, getReceiptBlockNumber(proposeReceipt), defaultLiveness); + + const { spy, logger } = makeSpyLogger(); + const params = await createParams("OptimisticOracleV2", optimisticOracleV2.address); + params.settleExcludeList = new Set([getProposalEventId(proposeReceipt)]); + await gasEstimator.update(); + await settleRequests(logger, params, gasEstimator); + + const settlementLogs = spy.getCalls().filter((call) => call.lastArg?.message === "Price Request Settled ✅"); + assert.equal(settlementLogs.length, 0, "Excluded proposal should not be settled"); + }); + + it("Settles only proposals in the include list", async function () { + await ( + await optimisticOracleV2.requestPrice(defaultOptimisticOracleV2Identifier, 0, ancillaryData, bondToken.address, 0) + ).wait(); + + const proposeReceipt = await ( + await optimisticOracleV2 + .connect(proposer) + .proposePrice( + await requester.getAddress(), + defaultOptimisticOracleV2Identifier, + 0, + ancillaryData, + ethers.utils.parseEther("1") + ) + ).wait(); + + await advanceTimerPastLiveness(timer, getReceiptBlockNumber(proposeReceipt), defaultLiveness); + + // An include list that does not contain the proposal: nothing settles. + { + const { spy, logger } = makeSpyLogger(); + const params = await createParams("OptimisticOracleV2", optimisticOracleV2.address); + params.settleIncludeList = new Set([proposalEventId(`0x${"0".repeat(64)}`, 0)]); + await gasEstimator.update(); + await settleRequests(logger, params, gasEstimator); + + const settlementLogs = spy.getCalls().filter((call) => call.lastArg?.message === "Price Request Settled ✅"); + assert.equal(settlementLogs.length, 0, "Proposal absent from the include list should not be settled"); + } + + // An include list containing the proposal: it settles. + { + const { spy, logger } = makeSpyLogger(); + const params = await createParams("OptimisticOracleV2", optimisticOracleV2.address); + params.settleIncludeList = new Set([getProposalEventId(proposeReceipt)]); + await gasEstimator.update(); + await settleRequests(logger, params, gasEstimator); + + const settlementLogs = spy.getCalls().filter((call) => call.lastArg?.message === "Price Request Settled ✅"); + assert.equal(settlementLogs.length, 1, "Proposal present in the include list should be settled"); + } + }); });