diff --git a/packages/monitor-v2/src/bot-oo/README.md b/packages/monitor-v2/src/bot-oo/README.md index 9a655f7616..644a5b66c0 100644 --- a/packages/monitor-v2/src/bot-oo/README.md +++ b/packages/monitor-v2/src/bot-oo/README.md @@ -5,6 +5,7 @@ The unified Optimistic Oracle bot performs off-chain actions related to multiple - **OptimisticOracle V1** (deprecated but still deployed) - **SkinnyOptimisticOracle** (gas-optimized version) - **OptimisticOracle V2** (current standard) +- **ManagedOptimisticOracle V2** (uses the OptimisticOracle V2 settlement interface) > Warning: This bot does not support SkinnyOptimisticOracleV2. That contract has a distinct interface from SkinnyOptimisticOracle and is intentionally out of scope here. @@ -16,7 +17,7 @@ node ./packages/monitor-v2/dist/bot-oo/index.js ## Required Environment Variables -- `ORACLE_TYPE`: Oracle contract type (`OptimisticOracle`, `SkinnyOptimisticOracle`, or `OptimisticOracleV2`) +- `ORACLE_TYPE`: Oracle contract type (`OptimisticOracle`, `SkinnyOptimisticOracle`, `OptimisticOracleV2`, or `ManagedOptimisticOracleV2`) - `ORACLE_ADDRESS`: Address of the Oracle contract to monitor ## Standard Environment Variables @@ -36,6 +37,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, where `logIndex` is the block-level index of the `ProposePrice` log (for example, ethers' `event.logIndex`), not its position within the transaction. The bot settles **only** these proposals. Takes precedence over `SETTLE_EXCLUDE_LIST`, so an explicit empty array (`[]`) settles **nothing**. When neither list is configured, `OptimisticOracleV2` preserves the existing settle-all behavior while `ManagedOptimisticOracleV2` defaults to an empty include list and settles nothing. OOv2 contract types only — setting it for another `ORACLE_TYPE` throws at startup. Example: `["0xabc...def:5"]`. +- `SETTLE_EXCLUDE_LIST`: JSON array of `":"` proposal identifiers to **skip**. Ignored when `SETTLE_INCLUDE_LIST` is set. An explicit empty array (`[]`) opts in to settling every eligible proposal. OOv2 contract types only — setting a non-empty list for another `ORACLE_TYPE` throws at startup. ## Behavior @@ -45,7 +48,8 @@ The bot operates with a unified dispatcher pattern: settleRequests() ├── OptimisticOracle → settleOOv1Requests() ├── SkinnyOptimisticOracle → settleSkinnyOORequests() -└── OptimisticOracleV2 → settleOOv2Requests() +├── OptimisticOracleV2 → settleOOv2Requests() +└── ManagedOptimisticOracleV2 → settleOOv2Requests() ``` For each Oracle type: diff --git a/packages/monitor-v2/src/bot-oo/SettleOOv2Requests.ts b/packages/monitor-v2/src/bot-oo/SettleOOv2Requests.ts index 8458dc1688..fb01d879d5 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,11 +21,52 @@ 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 skippedIds: string[] = []; + let skipped = 0; + 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++; + if (!settleIncludeList) skippedIds.push(id); + } + } + + logger.debug({ + at: "OOv2Bot", + message: "Applied include/exclude proposal filter", + mode: settleIncludeList ? "include" : "exclude", + listSize: (settleIncludeList ?? settleExcludeList)?.size, + kept: kept.length, + skipped, + ...(settleIncludeList ? {} : { skippedIds }), + }); + + return kept; +} + export async function settleOOv2Requests( logger: typeof Logger, params: MonitoringParams, gasEstimator: GasEstimator ): Promise { + if (params.oracleType === "ManagedOptimisticOracleV2" && !params.settleIncludeList && !params.settleExcludeList) { + throw new Error("Managed OOv2 settlement requires an include or exclude list"); + } + const oo = await getContractInstanceWithProvider( "OptimisticOracleV2", params.provider, @@ -99,7 +140,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/SettleRequests.ts b/packages/monitor-v2/src/bot-oo/SettleRequests.ts index f3e31469e2..2a06a5c282 100644 --- a/packages/monitor-v2/src/bot-oo/SettleRequests.ts +++ b/packages/monitor-v2/src/bot-oo/SettleRequests.ts @@ -22,6 +22,7 @@ export async function settleRequests( case "SkinnyOptimisticOracle": return settleSkinnyOORequests(logger, params, gasEstimator); case "OptimisticOracleV2": + case "ManagedOptimisticOracleV2": return settleOOv2Requests(logger, params, gasEstimator); default: throw new Error(`Unsupported oracle type: ${params.oracleType}`); diff --git a/packages/monitor-v2/src/bot-oo/common.ts b/packages/monitor-v2/src/bot-oo/common.ts index faf1ca56d2..651d8fadc8 100644 --- a/packages/monitor-v2/src/bot-oo/common.ts +++ b/packages/monitor-v2/src/bot-oo/common.ts @@ -3,17 +3,79 @@ 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"; +export type OracleType = + | "OptimisticOracle" + | "SkinnyOptimisticOracle" + | "OptimisticOracleV2" + | "ManagedOptimisticOracleV2"; 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/blank. +// An explicit empty array is accepted: an empty exclude list opts Managed OOv2 into settling everything, while an +// empty include list means settle nothing (the include list is exclusive). +export 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)) { + throw new Error(`${envName} must be a 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); +} + +// Parses SETTLE_INCLUDE_LIST/SETTLE_EXCLUDE_LIST for OOv2 contracts. An empty exclude list is accepted for any oracle +// type and explicitly opts Managed OOv2 into settling everything; an empty include list means "settle nothing", +// which non-OOv2 oracle types cannot honor. +export function parseSettleProposalIdLists( + env: NodeJS.ProcessEnv, + oracleType: OracleType +): { settleIncludeList?: Set; settleExcludeList?: Set } { + const configuredIncludeList = parseProposalIdList(env.SETTLE_INCLUDE_LIST, "SETTLE_INCLUDE_LIST"); + const settleExcludeList = parseProposalIdList(env.SETTLE_EXCLUDE_LIST, "SETTLE_EXCLUDE_LIST"); + if ( + (configuredIncludeList || settleExcludeList?.size) && + oracleType !== "OptimisticOracleV2" && + oracleType !== "ManagedOptimisticOracleV2" + ) + throw new Error( + "SETTLE_INCLUDE_LIST/SETTLE_EXCLUDE_LIST are only supported for OptimisticOracleV2 and ManagedOptimisticOracleV2" + ); + + // Default Managed OOv2 settlement to an empty include list so missing configuration cannot settle every proposal. + // An explicit empty exclude list remains the opt-in for settling everything. + const settleIncludeList = + oracleType === "ManagedOptimisticOracleV2" && configuredIncludeList === undefined && settleExcludeList === undefined + ? new Set() + : configuredIncludeList; + + return { settleIncludeList, settleExcludeList }; +} + export interface BotModes { settleRequestsEnabled: boolean; settleOnlyDisputed: boolean; // Supported for OptimisticOracleV2 (incl. ManagedOOv2); ignored for OOv1 and SkinnyOO. @@ -27,6 +89,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 (":"). OOv2 contract types 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 is invalid for ManagedOptimisticOracleV2. + settleIncludeList?: Set; + settleExcludeList?: Set; } export const initMonitoringParams = async (env: NodeJS.ProcessEnv): Promise => { @@ -42,13 +109,17 @@ 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..29a9441879 100644 --- a/packages/monitor-v2/test/OptimisticOracleV2Bot.ts +++ b/packages/monitor-v2/test/OptimisticOracleV2Bot.ts @@ -7,7 +7,8 @@ import { } from "@uma/contracts-node"; import { spyLogIncludes, spyLogLevel, GasEstimator } from "@uma/financial-templates-lib"; import { assert } from "chai"; -import { OracleType } from "../src/bot-oo/common"; +import { OracleType, parseProposalIdList, parseSettleProposalIdLists } 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,168 @@ 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("ManagedOptimisticOracleV2", 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"); + + const filterLog = getLast( + spy.getCalls().filter((call) => call.lastArg?.message === "Applied include/exclude proposal filter"), + "Expected include/exclude filter log" + ).lastArg; + assert.equal(filterLog.skipped, 1); + assert.deepEqual(filterLog.skippedIds, [getProposalEventId(proposeReceipt)]); + }); + + 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("ManagedOptimisticOracleV2", 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"); + + const filterLog = getLast( + spy.getCalls().filter((call) => call.lastArg?.message === "Applied include/exclude proposal filter"), + "Expected include/exclude filter log" + ).lastArg; + assert.equal(filterLog.skipped, 1); + assert.notProperty(filterLog, "skippedIds"); + } + + // An include list containing the proposal: it settles. + { + const { spy, logger } = makeSpyLogger(); + const params = await createParams("ManagedOptimisticOracleV2", 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"); + } + }); + + it("Accepts explicit empty include/exclude lists", async function () { + // Templated deployments commonly render optional list env vars as "[]"; this must not throw. + const includeList = parseProposalIdList("[]", "SETTLE_INCLUDE_LIST"); + assert.instanceOf(includeList, Set); + assert.equal(includeList?.size, 0); + + const excludeList = parseProposalIdList("[]", "SETTLE_EXCLUDE_LIST"); + assert.instanceOf(excludeList, Set); + assert.equal(excludeList?.size, 0); + }); + + it("Defaults Managed OOv2 settlements to an empty include list", async function () { + const { settleIncludeList, settleExcludeList } = parseSettleProposalIdLists( + {} as NodeJS.ProcessEnv, + "ManagedOptimisticOracleV2" + ); + assert.instanceOf(settleIncludeList, Set); + assert.equal(settleIncludeList?.size, 0); + assert.isUndefined(settleExcludeList); + + const explicitSettleAll = parseSettleProposalIdLists( + { SETTLE_EXCLUDE_LIST: "[]" } as NodeJS.ProcessEnv, + "ManagedOptimisticOracleV2" + ); + assert.isUndefined(explicitSettleAll.settleIncludeList); + assert.instanceOf(explicitSettleAll.settleExcludeList, Set); + assert.equal(explicitSettleAll.settleExcludeList?.size, 0); + + const standardOOv2 = parseSettleProposalIdLists({} as NodeJS.ProcessEnv, "OptimisticOracleV2"); + assert.isUndefined(standardOOv2.settleIncludeList); + assert.isUndefined(standardOOv2.settleExcludeList); + }); + + it("Rejects Managed OOv2 settlement params without an include or exclude list", async function () { + const { logger } = makeSpyLogger(); + const params = await createParams("ManagedOptimisticOracleV2", optimisticOracleV2.address); + params.settleIncludeList = undefined; + params.settleExcludeList = undefined; + + let error: unknown; + try { + await settleRequests(logger, params, gasEstimator); + } catch (err) { + error = err; + } + + assert.instanceOf(error, Error); + assert.match((error as Error).message, /Managed OOv2 settlement requires an include or exclude list/); + }); + + it("Rejects include/exclude lists for non-OOv2 oracle types", async function () { + // Only the OOv2 settler applies these lists; silently ignoring them would settle proposals the operator + // intended to skip, so startup must fail instead. + const env = { SETTLE_EXCLUDE_LIST: JSON.stringify([`0x${"0".repeat(64)}:0`]) } as NodeJS.ProcessEnv; + assert.throws( + () => parseSettleProposalIdLists(env, "OptimisticOracle"), + /only supported for OptimisticOracleV2 and ManagedOptimisticOracleV2/ + ); + assert.throws( + () => parseSettleProposalIdLists(env, "SkinnyOptimisticOracle"), + /only supported for OptimisticOracleV2 and ManagedOptimisticOracleV2/ + ); + assert.doesNotThrow(() => parseSettleProposalIdLists(env, "OptimisticOracleV2")); + assert.doesNotThrow(() => parseSettleProposalIdLists(env, "ManagedOptimisticOracleV2")); + + // An empty exclude list skips nothing and behaves the same as unset, so it must not block startup. + const emptyExcludeEnv = { SETTLE_EXCLUDE_LIST: "[]" } as NodeJS.ProcessEnv; + assert.doesNotThrow(() => parseSettleProposalIdLists(emptyExcludeEnv, "OptimisticOracle")); + + // An empty include list means "settle nothing", which non-OOv2 cannot honor, so it must still throw. + const emptyIncludeEnv = { SETTLE_INCLUDE_LIST: "[]" } as NodeJS.ProcessEnv; + assert.throws( + () => parseSettleProposalIdLists(emptyIncludeEnv, "OptimisticOracle"), + /only supported for OptimisticOracleV2 and ManagedOptimisticOracleV2/ + ); + }); }); diff --git a/packages/monitor-v2/test/helpers/monitoring.ts b/packages/monitor-v2/test/helpers/monitoring.ts index 79ae0b1ec9..94d5814d6e 100644 --- a/packages/monitor-v2/test/helpers/monitoring.ts +++ b/packages/monitor-v2/test/helpers/monitoring.ts @@ -43,6 +43,7 @@ export async function makeMonitoringParamsOO( contractAddress, settleBatchSize: 1, settleMinProposalAgeSeconds: 0, + ...(oracleType === "ManagedOptimisticOracleV2" ? { settleExcludeList: new Set() } : {}), }; }