From ee1589f7878b09d5fbd4c93b3f08dbc9c0f18937 Mon Sep 17 00:00:00 2001 From: Pablo Maldonado Date: Fri, 24 Jul 2026 17:30:31 +0100 Subject: [PATCH 1/6] fix(bot-oo): fetch managed include events directly Signed-off-by: Pablo Maldonado --- .../src/bot-oo/SettleOOv2Requests.ts | 128 +++++++++++------- .../monitor-v2/test/OptimisticOracleV2Bot.ts | 10 +- 2 files changed, 89 insertions(+), 49 deletions(-) diff --git a/packages/monitor-v2/src/bot-oo/SettleOOv2Requests.ts b/packages/monitor-v2/src/bot-oo/SettleOOv2Requests.ts index fb01d879d5..3b04665a9d 100644 --- a/packages/monitor-v2/src/bot-oo/SettleOOv2Requests.ts +++ b/packages/monitor-v2/src/bot-oo/SettleOOv2Requests.ts @@ -58,6 +58,31 @@ function filterByIncludeExclude( return kept; } +async function getManagedIncludedProposals( + oo: OptimisticOracleV2Ethers, + includedProposalIds: Set +): Promise { + const transactionHashes = [...new Set([...includedProposalIds].map((id) => id.split(":")[0]))]; + const receipts = await Promise.all( + transactionHashes.map((transactionHash) => oo.provider.getTransactionReceipt(transactionHash)) + ); + const blockNumbers = [...new Set(receipts.flatMap((receipt) => (receipt ? [receipt.blockNumber] : [])))]; + const proposals = ( + await Promise.all( + blockNumbers.map((blockNumber) => oo.queryFilter(oo.filters.ProposePrice(), blockNumber, blockNumber)) + ) + ).flat(); + const proposalsById = new Map( + proposals.map((proposal) => [proposalEventId(proposal.transactionHash, proposal.logIndex), proposal] as const) + ); + + return [...includedProposalIds].map((id) => { + const proposal = proposalsById.get(id); + if (!proposal) throw new Error(`Could not find included ProposePrice event ${id} on ${oo.address}`); + return proposal; + }); +} + export async function settleOOv2Requests( logger: typeof Logger, params: MonitoringParams, @@ -73,69 +98,80 @@ export async function settleOOv2Requests( params.contractAddress ); - const searchConfig = await computeEventSearch( - params.provider, - params.blockFinder, - params.timeLookback, - params.maxBlockLookBack - ); - - logger.debug({ - at: "OOv2Bot", - message: "Querying ProposePrice events", - fromBlock: searchConfig.fromBlock, - toBlock: searchConfig.toBlock, - maxBlockLookBack: searchConfig.maxBlockLookBack, - }); - const proposalsStartedAt = Date.now(); let proposals: ProposePriceEvent[]; - try { - proposals = await paginatedEventQuery(oo, oo.filters.ProposePrice(), searchConfig); + let settlements: SettleEvent[]; + if (params.oracleType === "ManagedOptimisticOracleV2" && params.settleIncludeList) { + proposals = await getManagedIncludedProposals(oo, params.settleIncludeList); + settlements = []; logger.debug({ at: "OOv2Bot", - message: "Queried ProposePrice events", + message: "Queried included ProposePrice events", + requested: params.settleIncludeList.size, count: proposals.length, - elapsedMs: Date.now() - proposalsStartedAt, }); - } catch (error) { - logger.error({ + } else { + const searchConfig = await computeEventSearch( + params.provider, + params.blockFinder, + params.timeLookback, + params.maxBlockLookBack + ); + + logger.debug({ at: "OOv2Bot", - message: "Failed querying ProposePrice events", + message: "Querying ProposePrice events", fromBlock: searchConfig.fromBlock, toBlock: searchConfig.toBlock, maxBlockLookBack: searchConfig.maxBlockLookBack, - ...getSettleTxErrorLogFields(error), }); - throw error; - } + const proposalsStartedAt = Date.now(); + try { + proposals = await paginatedEventQuery(oo, oo.filters.ProposePrice(), searchConfig); + logger.debug({ + at: "OOv2Bot", + message: "Queried ProposePrice events", + count: proposals.length, + elapsedMs: Date.now() - proposalsStartedAt, + }); + } catch (error) { + logger.error({ + at: "OOv2Bot", + message: "Failed querying ProposePrice events", + fromBlock: searchConfig.fromBlock, + toBlock: searchConfig.toBlock, + maxBlockLookBack: searchConfig.maxBlockLookBack, + ...getSettleTxErrorLogFields(error), + }); + throw error; + } - logger.debug({ - at: "OOv2Bot", - message: "Querying Settle events", - fromBlock: searchConfig.fromBlock, - toBlock: searchConfig.toBlock, - maxBlockLookBack: searchConfig.maxBlockLookBack, - }); - const settlementsStartedAt = Date.now(); - let settlements: SettleEvent[]; - try { - settlements = await paginatedEventQuery(oo, oo.filters.Settle(), searchConfig); logger.debug({ at: "OOv2Bot", - message: "Queried Settle events", - count: settlements.length, - elapsedMs: Date.now() - settlementsStartedAt, - }); - } catch (error) { - logger.error({ - at: "OOv2Bot", - message: "Failed querying Settle events", + message: "Querying Settle events", fromBlock: searchConfig.fromBlock, toBlock: searchConfig.toBlock, maxBlockLookBack: searchConfig.maxBlockLookBack, - ...getSettleTxErrorLogFields(error), }); - throw error; + const settlementsStartedAt = Date.now(); + try { + settlements = await paginatedEventQuery(oo, oo.filters.Settle(), searchConfig); + logger.debug({ + at: "OOv2Bot", + message: "Queried Settle events", + count: settlements.length, + elapsedMs: Date.now() - settlementsStartedAt, + }); + } catch (error) { + logger.error({ + at: "OOv2Bot", + message: "Failed querying Settle events", + fromBlock: searchConfig.fromBlock, + toBlock: searchConfig.toBlock, + maxBlockLookBack: searchConfig.maxBlockLookBack, + ...getSettleTxErrorLogFields(error), + }); + throw error; + } } const settledKeys = new Set(settlements.map((e) => requestKey(e.args))); diff --git a/packages/monitor-v2/test/OptimisticOracleV2Bot.ts b/packages/monitor-v2/test/OptimisticOracleV2Bot.ts index 29a9441879..038ffc2821 100644 --- a/packages/monitor-v2/test/OptimisticOracleV2Bot.ts +++ b/packages/monitor-v2/test/OptimisticOracleV2Bot.ts @@ -6,6 +6,7 @@ import { TimerEthers, } from "@uma/contracts-node"; import { spyLogIncludes, spyLogLevel, GasEstimator } from "@uma/financial-templates-lib"; +import { BlockFinder } from "@uma/sdk"; import { assert } from "chai"; import { OracleType, parseProposalIdList, parseSettleProposalIdLists } from "../src/bot-oo/common"; import { proposalEventId } from "../src/bot-oo/requestKey"; @@ -536,11 +537,11 @@ describe("OptimisticOracleV2Bot", function () { await advanceTimerPastLiveness(timer, getReceiptBlockNumber(proposeReceipt), defaultLiveness); - // An include list that does not contain the proposal: nothing settles. + // An empty include list settles nothing. { const { spy, logger } = makeSpyLogger(); const params = await createParams("ManagedOptimisticOracleV2", optimisticOracleV2.address); - params.settleIncludeList = new Set([proposalEventId(`0x${"0".repeat(64)}`, 0)]); + params.settleIncludeList = new Set(); await gasEstimator.update(); await settleRequests(logger, params, gasEstimator); @@ -551,7 +552,7 @@ describe("OptimisticOracleV2Bot", function () { spy.getCalls().filter((call) => call.lastArg?.message === "Applied include/exclude proposal filter"), "Expected include/exclude filter log" ).lastArg; - assert.equal(filterLog.skipped, 1); + assert.equal(filterLog.skipped, 0); assert.notProperty(filterLog, "skippedIds"); } @@ -560,6 +561,9 @@ describe("OptimisticOracleV2Bot", function () { const { spy, logger } = makeSpyLogger(); const params = await createParams("ManagedOptimisticOracleV2", optimisticOracleV2.address); params.settleIncludeList = new Set([getProposalEventId(proposeReceipt)]); + // Exclude the proposal from the historical event range to exercise the direct include-list lookup. + params.timeLookback = 0; + params.blockFinder = new BlockFinder(params.provider.getBlock.bind(params.provider), undefined, params.chainId); await gasEstimator.update(); await settleRequests(logger, params, gasEstimator); From 0f737fb40347063e25df18174cc76c4da5be41d1 Mon Sep 17 00:00:00 2001 From: Pablo Maldonado Date: Mon, 27 Jul 2026 10:24:55 +0100 Subject: [PATCH 2/6] fix(bot-oo): preserve managed disputed settlements Signed-off-by: Pablo Maldonado --- packages/monitor-v2/src/bot-oo/README.md | 6 +- .../src/bot-oo/SettleOOv2Requests.ts | 142 +++++++++++------- packages/monitor-v2/src/bot-oo/common.ts | 21 ++- .../monitor-v2/test/OptimisticOracleV2Bot.ts | 106 +++++++++++-- 4 files changed, 195 insertions(+), 80 deletions(-) diff --git a/packages/monitor-v2/src/bot-oo/README.md b/packages/monitor-v2/src/bot-oo/README.md index 644a5b66c0..c128b0bd8c 100644 --- a/packages/monitor-v2/src/bot-oo/README.md +++ b/packages/monitor-v2/src/bot-oo/README.md @@ -36,9 +36,9 @@ node ./packages/monitor-v2/dist/bot-oo/index.js - `SETTLE_DELAY`: Lookback period in seconds to detect settleable requests (default `300`). - `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. +- `SETTLE_ONLY_DISPUTED`: When `true`, standard `OptimisticOracleV2` only settles resolved disputes (`false` by default). Managed OOv2 always preserves normal resolved-dispute settlement and uses the settlement lists for other requests. 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. For standard OOv2 the bot settles **only** these proposals. For Managed OOv2 the listed proposals are fetched directly and added to the normal resolved-dispute candidates. Takes precedence over `SETTLE_EXCLUDE_LIST`; an empty array (`[]`) prevents non-resolved Managed proposals from settling while leaving resolved disputes unaffected. 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. For Managed OOv2 it filters only non-resolved proposals; resolved disputes remain unaffected. An explicit empty array (`[]`) opts in to settling every otherwise eligible proposal. OOv2 contract types only — setting a non-empty list for another `ORACLE_TYPE` throws at startup. ## Behavior diff --git a/packages/monitor-v2/src/bot-oo/SettleOOv2Requests.ts b/packages/monitor-v2/src/bot-oo/SettleOOv2Requests.ts index 3b04665a9d..11fa80bd95 100644 --- a/packages/monitor-v2/src/bot-oo/SettleOOv2Requests.ts +++ b/packages/monitor-v2/src/bot-oo/SettleOOv2Requests.ts @@ -21,9 +21,8 @@ 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. +// Applies the standard OOv2 include/exclude proposal lists. Managed OOv2 applies them after checking request state so +// resolved disputes preserve their normal settlement behavior. function filterByIncludeExclude( logger: typeof Logger, params: MonitoringParams, @@ -98,79 +97,95 @@ export async function settleOOv2Requests( params.contractAddress ); + const searchConfig = await computeEventSearch( + params.provider, + params.blockFinder, + params.timeLookback, + params.maxBlockLookBack + ); + + logger.debug({ + at: "OOv2Bot", + message: "Querying ProposePrice events", + fromBlock: searchConfig.fromBlock, + toBlock: searchConfig.toBlock, + maxBlockLookBack: searchConfig.maxBlockLookBack, + }); + const proposalsStartedAt = Date.now(); let proposals: ProposePriceEvent[]; - let settlements: SettleEvent[]; - if (params.oracleType === "ManagedOptimisticOracleV2" && params.settleIncludeList) { - proposals = await getManagedIncludedProposals(oo, params.settleIncludeList); - settlements = []; + try { + proposals = await paginatedEventQuery(oo, oo.filters.ProposePrice(), searchConfig); logger.debug({ at: "OOv2Bot", - message: "Queried included ProposePrice events", - requested: params.settleIncludeList.size, + message: "Queried ProposePrice events", count: proposals.length, + elapsedMs: Date.now() - proposalsStartedAt, }); - } else { - const searchConfig = await computeEventSearch( - params.provider, - params.blockFinder, - params.timeLookback, - params.maxBlockLookBack - ); - - logger.debug({ + } catch (error) { + logger.error({ at: "OOv2Bot", - message: "Querying ProposePrice events", + message: "Failed querying ProposePrice events", fromBlock: searchConfig.fromBlock, toBlock: searchConfig.toBlock, maxBlockLookBack: searchConfig.maxBlockLookBack, + ...getSettleTxErrorLogFields(error), }); - const proposalsStartedAt = Date.now(); - try { - proposals = await paginatedEventQuery(oo, oo.filters.ProposePrice(), searchConfig); - logger.debug({ - at: "OOv2Bot", - message: "Queried ProposePrice events", - count: proposals.length, - elapsedMs: Date.now() - proposalsStartedAt, - }); - } catch (error) { - logger.error({ - at: "OOv2Bot", - message: "Failed querying ProposePrice events", - fromBlock: searchConfig.fromBlock, - toBlock: searchConfig.toBlock, - maxBlockLookBack: searchConfig.maxBlockLookBack, - ...getSettleTxErrorLogFields(error), - }); - throw error; - } + throw error; + } + logger.debug({ + at: "OOv2Bot", + message: "Querying Settle events", + fromBlock: searchConfig.fromBlock, + toBlock: searchConfig.toBlock, + maxBlockLookBack: searchConfig.maxBlockLookBack, + }); + const settlementsStartedAt = Date.now(); + let settlements: SettleEvent[]; + try { + settlements = await paginatedEventQuery(oo, oo.filters.Settle(), searchConfig); logger.debug({ at: "OOv2Bot", - message: "Querying Settle events", + message: "Queried Settle events", + count: settlements.length, + elapsedMs: Date.now() - settlementsStartedAt, + }); + } catch (error) { + logger.error({ + at: "OOv2Bot", + message: "Failed querying Settle events", fromBlock: searchConfig.fromBlock, toBlock: searchConfig.toBlock, maxBlockLookBack: searchConfig.maxBlockLookBack, + ...getSettleTxErrorLogFields(error), }); - const settlementsStartedAt = Date.now(); + throw error; + } + + if (params.oracleType === "ManagedOptimisticOracleV2" && params.settleIncludeList) { try { - settlements = await paginatedEventQuery(oo, oo.filters.Settle(), searchConfig); + const includedProposals = await getManagedIncludedProposals(oo, params.settleIncludeList); + const proposalsById = new Map( + proposals.map((proposal) => [proposalEventId(proposal.transactionHash, proposal.logIndex), proposal] as const) + ); + for (const proposal of includedProposals) { + proposalsById.set(proposalEventId(proposal.transactionHash, proposal.logIndex), proposal); + } + proposals = [...proposalsById.values()]; logger.debug({ at: "OOv2Bot", - message: "Queried Settle events", - count: settlements.length, - elapsedMs: Date.now() - settlementsStartedAt, + message: "Queried included ProposePrice events", + requested: params.settleIncludeList.size, + count: includedProposals.length, }); } catch (error) { + // Manual candidates fail closed, but their lookup must not block historical disputed settlements. logger.error({ at: "OOv2Bot", - message: "Failed querying Settle events", - fromBlock: searchConfig.fromBlock, - toBlock: searchConfig.toBlock, - maxBlockLookBack: searchConfig.maxBlockLookBack, + message: "Failed querying included ProposePrice events", + requested: params.settleIncludeList.size, ...getSettleTxErrorLogFields(error), }); - throw error; } } @@ -178,7 +193,10 @@ export async function settleOOv2Requests( const unsettledRequests = proposals.filter((e) => !settledKeys.has(requestKey(e.args))); - const requestsToSettle = filterByIncludeExclude(logger, params, unsettledRequests); + const requestsToSettle = + params.oracleType === "ManagedOptimisticOracleV2" + ? unsettledRequests + : filterByIncludeExclude(logger, params, unsettledRequests); const requestsToSettleTxCount = params.settleBatchSize > 1 ? Math.ceil(requestsToSettle.length / params.settleBatchSize) : requestsToSettle.length; @@ -234,15 +252,31 @@ export async function settleOOv2Requests( } } - // When settleOnlyDisputed is enabled, check on-chain state and skip undisputed requests. - if (params.botModes.settleOnlyDisputed) { + // Managed OOv2 always preserves normal disputed settlement; its lists only govern non-resolved requests. + if (params.oracleType === "ManagedOptimisticOracleV2" || params.botModes.settleOnlyDisputed) { const state = await oo.getState( req.args.requester, req.args.identifier, req.args.timestamp, req.args.ancillaryData ); - if (state !== STATE_RESOLVED) { + if (params.oracleType === "ManagedOptimisticOracleV2" && state !== STATE_RESOLVED) { + const id = proposalEventId(req.transactionHash, req.logIndex); + const allowedBySettlementLists = params.settleIncludeList + ? params.settleIncludeList.has(id) + : !params.settleExcludeList?.has(id); + if (!allowedBySettlementLists) { + logger.debug({ + at: "OOv2Bot", + message: "Skipping non-resolved Managed OOv2 request outside settlement lists", + requestKey: requestKey(req.args), + proposalEventId: id, + state, + mode: params.settleIncludeList ? "include" : "exclude", + }); + return null; + } + } else if (params.oracleType !== "ManagedOptimisticOracleV2" && state !== STATE_RESOLVED) { logger.debug({ at: "OOv2Bot", message: "Skipping non-disputed request (settleOnlyDisputed)", diff --git a/packages/monitor-v2/src/bot-oo/common.ts b/packages/monitor-v2/src/bot-oo/common.ts index 651d8fadc8..ebc3456255 100644 --- a/packages/monitor-v2/src/bot-oo/common.ts +++ b/packages/monitor-v2/src/bot-oo/common.ts @@ -23,8 +23,8 @@ function getNonNegativeNumber(value: string | undefined, defaultValue: number): // 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). +// An explicit empty array is accepted. For Managed OOv2, an empty exclude list allows every eligible non-resolved +// proposal while an empty include list allows none; resolved disputes are unaffected. export function parseProposalIdList(value: string | undefined, envName: string): Set | undefined { if (value === undefined || value.trim() === "") return undefined; @@ -48,9 +48,8 @@ export function parseProposalIdList(value: string | undefined, envName: string): 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. +// Parses SETTLE_INCLUDE_LIST/SETTLE_EXCLUDE_LIST for OOv2 contracts. For Managed OOv2 the lists govern non-resolved +// requests only; normal resolved-dispute settlement is always preserved. export function parseSettleProposalIdLists( env: NodeJS.ProcessEnv, oracleType: OracleType @@ -66,8 +65,8 @@ export function parseSettleProposalIdLists( "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. + // Default Managed OOv2 settlement to an empty include list so missing configuration cannot settle non-resolved + // proposals. An explicit empty exclude list remains the opt-in for settling all eligible non-resolved proposals. const settleIncludeList = oracleType === "ManagedOptimisticOracleV2" && configuredIncludeList === undefined && settleExcludeList === undefined ? new Set() @@ -78,7 +77,7 @@ export function parseSettleProposalIdLists( export interface BotModes { settleRequestsEnabled: boolean; - settleOnlyDisputed: boolean; // Supported for OptimisticOracleV2 (incl. ManagedOOv2); ignored for OOv1 and SkinnyOO. + settleOnlyDisputed: boolean; // Supported for standard OOv2; ignored for OOv1, SkinnyOO, and Managed OOv2. } export interface MonitoringParams extends BaseMonitoringParams { @@ -89,9 +88,9 @@ 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. + // Include/exclude lists of proposal event ids (":"). For standard OOv2 they filter all proposals. + // For Managed OOv2 they filter only non-resolved proposals; resolved disputes always follow normal settlement. + // The include list takes precedence over the exclude list. Both undefined is invalid for ManagedOptimisticOracleV2. settleIncludeList?: Set; settleExcludeList?: Set; } diff --git a/packages/monitor-v2/test/OptimisticOracleV2Bot.ts b/packages/monitor-v2/test/OptimisticOracleV2Bot.ts index 038ffc2821..c5a649bbe4 100644 --- a/packages/monitor-v2/test/OptimisticOracleV2Bot.ts +++ b/packages/monitor-v2/test/OptimisticOracleV2Bot.ts @@ -473,6 +473,15 @@ describe("OptimisticOracleV2Bot", function () { settleRequestsEnabled: false, settleOnlyDisputed: true, }); + params.settleIncludeList = new Set(); + 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, "Standard OOv2 include lists must still filter resolved disputes"); + + spy.resetHistory(); + params.settleIncludeList = undefined; await gasEstimator.update(); await settleRequests(logger, params, gasEstimator); @@ -482,7 +491,71 @@ describe("OptimisticOracleV2Bot", function () { assert.isAbove(settledIndex, -1, "Disputed request should be settled when settleOnlyDisputed is true"); }); - it("Skips proposals in the exclude list", async function () { + for (const listMode of ["empty include", "invalid include", "matching exclude"] as const) { + it(`Managed OOv2 settles disputed requests when using the ${listMode} 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 ( + await optimisticOracleV2 + .connect(disputer) + .disputePrice(await requester.getAddress(), defaultOptimisticOracleV2Identifier, 0, ancillaryData) + ).wait(); + + const pending = await mockOracle.getPendingQueries(); + const last = getLast(pending, "Expected a pending DVM query"); + await ( + await mockOracle.pushPrice(last.identifier, last.time, last.ancillaryData, ethers.utils.parseEther("1")) + ).wait(); + + const { spy, logger } = makeSpyLogger(); + const params = await createParams("ManagedOptimisticOracleV2", optimisticOracleV2.address); + const id = getProposalEventId(proposeReceipt); + if (listMode === "empty include") { + params.settleIncludeList = new Set(); + params.settleExcludeList = undefined; + } else if (listMode === "invalid include") { + params.settleIncludeList = new Set([proposalEventId(`0x${"0".repeat(64)}`, 0)]); + params.settleExcludeList = undefined; + } else { + params.settleIncludeList = undefined; + params.settleExcludeList = new Set([id]); + } + 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, "Settlement lists must not block normal disputed settlement"); + + if (listMode === "invalid include") { + assert.isTrue( + spy.getCalls().some((call) => call.lastArg?.message === "Failed querying included ProposePrice events"), + "Invalid direct includes should be logged without blocking disputed settlement" + ); + } + }); + } + + it("Skips non-disputed proposals in the exclude list", async function () { await ( await optimisticOracleV2.requestPrice(defaultOptimisticOracleV2Identifier, 0, ancillaryData, bondToken.address, 0) ).wait(); @@ -510,15 +583,19 @@ describe("OptimisticOracleV2Bot", function () { 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" + const skipLog = getLast( + spy + .getCalls() + .filter( + (call) => call.lastArg?.message === "Skipping non-resolved Managed OOv2 request outside settlement lists" + ), + "Expected settlement list skip log" ).lastArg; - assert.equal(filterLog.skipped, 1); - assert.deepEqual(filterLog.skippedIds, [getProposalEventId(proposeReceipt)]); + assert.equal(skipLog.proposalEventId, getProposalEventId(proposeReceipt)); + assert.equal(skipLog.mode, "exclude"); }); - it("Settles only proposals in the include list", async function () { + it("Uses the include list for non-disputed Managed OOv2 proposals", async function () { await ( await optimisticOracleV2.requestPrice(defaultOptimisticOracleV2Identifier, 0, ancillaryData, bondToken.address, 0) ).wait(); @@ -548,12 +625,16 @@ describe("OptimisticOracleV2Bot", function () { 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" + const skipLog = getLast( + spy + .getCalls() + .filter( + (call) => call.lastArg?.message === "Skipping non-resolved Managed OOv2 request outside settlement lists" + ), + "Expected settlement list skip log" ).lastArg; - assert.equal(filterLog.skipped, 0); - assert.notProperty(filterLog, "skippedIds"); + assert.equal(skipLog.proposalEventId, getProposalEventId(proposeReceipt)); + assert.equal(skipLog.mode, "include"); } // An include list containing the proposal: it settles. @@ -561,6 +642,7 @@ describe("OptimisticOracleV2Bot", function () { const { spy, logger } = makeSpyLogger(); const params = await createParams("ManagedOptimisticOracleV2", optimisticOracleV2.address); params.settleIncludeList = new Set([getProposalEventId(proposeReceipt)]); + params.botModes.settleOnlyDisputed = true; // Exclude the proposal from the historical event range to exercise the direct include-list lookup. params.timeLookback = 0; params.blockFinder = new BlockFinder(params.provider.getBlock.bind(params.provider), undefined, params.chainId); From a57bafedc3094d8dab225d17350f92572186d8a2 Mon Sep 17 00:00:00 2001 From: Pablo Maldonado Date: Mon, 27 Jul 2026 11:19:39 +0100 Subject: [PATCH 3/6] fix(bot-oo): require managed settlement includes Signed-off-by: Pablo Maldonado --- packages/monitor-v2/src/bot-oo/README.md | 6 +- .../src/bot-oo/SettleOOv2Requests.ts | 16 +- packages/monitor-v2/src/bot-oo/common.ts | 19 +-- .../monitor-v2/test/OptimisticOracleV2Bot.ts | 137 +++++++++--------- .../monitor-v2/test/helpers/monitoring.ts | 2 +- 5 files changed, 91 insertions(+), 89 deletions(-) diff --git a/packages/monitor-v2/src/bot-oo/README.md b/packages/monitor-v2/src/bot-oo/README.md index c128b0bd8c..849d7466bd 100644 --- a/packages/monitor-v2/src/bot-oo/README.md +++ b/packages/monitor-v2/src/bot-oo/README.md @@ -36,9 +36,9 @@ node ./packages/monitor-v2/dist/bot-oo/index.js - `SETTLE_DELAY`: Lookback period in seconds to detect settleable requests (default `300`). - `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`, standard `OptimisticOracleV2` only settles resolved disputes (`false` by default). Managed OOv2 always preserves normal resolved-dispute settlement and uses the settlement lists for other requests. 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. For standard OOv2 the bot settles **only** these proposals. For Managed OOv2 the listed proposals are fetched directly and added to the normal resolved-dispute candidates. Takes precedence over `SETTLE_EXCLUDE_LIST`; an empty array (`[]`) prevents non-resolved Managed proposals from settling while leaving resolved disputes unaffected. 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. For Managed OOv2 it filters only non-resolved proposals; resolved disputes remain unaffected. An explicit empty array (`[]`) opts in to settling every otherwise eligible proposal. OOv2 contract types only — setting a non-empty list for another `ORACLE_TYPE` throws at startup. +- `SETTLE_ONLY_DISPUTED`: When `true`, standard `OptimisticOracleV2` only settles resolved disputes (`false` by default). Managed OOv2 always preserves normal resolved-dispute settlement and uses its include list for other requests. 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. For standard OOv2 the bot settles **only** these proposals. For Managed OOv2 this is the only supported settlement list: listed proposals are fetched directly and added to the normal resolved-dispute candidates. Managed OOv2 defaults to an empty include list, which prevents all non-resolved proposals from settling while leaving resolved disputes unaffected. Example: `["0xabc...def:5"]`. +- `SETTLE_EXCLUDE_LIST`: JSON array of `":"` proposal identifiers to **skip** in standard OOv2. Ignored there when `SETTLE_INCLUDE_LIST` is set. Managed OOv2 does not support this setting and throws at startup whenever it is configured, including as an empty array (`[]`). ## Behavior diff --git a/packages/monitor-v2/src/bot-oo/SettleOOv2Requests.ts b/packages/monitor-v2/src/bot-oo/SettleOOv2Requests.ts index 11fa80bd95..0d431e1932 100644 --- a/packages/monitor-v2/src/bot-oo/SettleOOv2Requests.ts +++ b/packages/monitor-v2/src/bot-oo/SettleOOv2Requests.ts @@ -87,8 +87,10 @@ export async function settleOOv2Requests( 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"); + if (params.oracleType === "ManagedOptimisticOracleV2") { + if (params.settleExcludeList !== undefined) + throw new Error("SETTLE_EXCLUDE_LIST is not supported for ManagedOptimisticOracleV2; use SETTLE_INCLUDE_LIST"); + if (!params.settleIncludeList) throw new Error("Managed OOv2 settlement requires an include list"); } const oo = await getContractInstanceWithProvider( @@ -252,7 +254,7 @@ export async function settleOOv2Requests( } } - // Managed OOv2 always preserves normal disputed settlement; its lists only govern non-resolved requests. + // Managed OOv2 always preserves normal disputed settlement; its include list governs non-resolved requests. if (params.oracleType === "ManagedOptimisticOracleV2" || params.botModes.settleOnlyDisputed) { const state = await oo.getState( req.args.requester, @@ -262,17 +264,13 @@ export async function settleOOv2Requests( ); if (params.oracleType === "ManagedOptimisticOracleV2" && state !== STATE_RESOLVED) { const id = proposalEventId(req.transactionHash, req.logIndex); - const allowedBySettlementLists = params.settleIncludeList - ? params.settleIncludeList.has(id) - : !params.settleExcludeList?.has(id); - if (!allowedBySettlementLists) { + if (!params.settleIncludeList?.has(id)) { logger.debug({ at: "OOv2Bot", - message: "Skipping non-resolved Managed OOv2 request outside settlement lists", + message: "Skipping non-resolved Managed OOv2 request outside settlement include list", requestKey: requestKey(req.args), proposalEventId: id, state, - mode: params.settleIncludeList ? "include" : "exclude", }); return null; } diff --git a/packages/monitor-v2/src/bot-oo/common.ts b/packages/monitor-v2/src/bot-oo/common.ts index ebc3456255..8607989421 100644 --- a/packages/monitor-v2/src/bot-oo/common.ts +++ b/packages/monitor-v2/src/bot-oo/common.ts @@ -23,8 +23,7 @@ function getNonNegativeNumber(value: string | undefined, defaultValue: number): // 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. For Managed OOv2, an empty exclude list allows every eligible non-resolved -// proposal while an empty include list allows none; resolved disputes are unaffected. +// An explicit empty array is accepted; oracle-specific support is validated separately. export function parseProposalIdList(value: string | undefined, envName: string): Set | undefined { if (value === undefined || value.trim() === "") return undefined; @@ -48,13 +47,16 @@ export function parseProposalIdList(value: string | undefined, envName: string): return new Set(ids); } -// Parses SETTLE_INCLUDE_LIST/SETTLE_EXCLUDE_LIST for OOv2 contracts. For Managed OOv2 the lists govern non-resolved -// requests only; normal resolved-dispute settlement is always preserved. +// Parses SETTLE_INCLUDE_LIST/SETTLE_EXCLUDE_LIST for OOv2 contracts. Managed OOv2 only supports an include list for +// non-resolved requests; normal resolved-dispute settlement is always preserved. export function parseSettleProposalIdLists( env: NodeJS.ProcessEnv, oracleType: OracleType ): { settleIncludeList?: Set; settleExcludeList?: Set } { const configuredIncludeList = parseProposalIdList(env.SETTLE_INCLUDE_LIST, "SETTLE_INCLUDE_LIST"); + if (oracleType === "ManagedOptimisticOracleV2" && env.SETTLE_EXCLUDE_LIST !== undefined) + throw new Error("SETTLE_EXCLUDE_LIST is not supported for ManagedOptimisticOracleV2; use SETTLE_INCLUDE_LIST"); + const settleExcludeList = parseProposalIdList(env.SETTLE_EXCLUDE_LIST, "SETTLE_EXCLUDE_LIST"); if ( (configuredIncludeList || settleExcludeList?.size) && @@ -66,9 +68,9 @@ export function parseSettleProposalIdLists( ); // Default Managed OOv2 settlement to an empty include list so missing configuration cannot settle non-resolved - // proposals. An explicit empty exclude list remains the opt-in for settling all eligible non-resolved proposals. + // proposals. const settleIncludeList = - oracleType === "ManagedOptimisticOracleV2" && configuredIncludeList === undefined && settleExcludeList === undefined + oracleType === "ManagedOptimisticOracleV2" && configuredIncludeList === undefined ? new Set() : configuredIncludeList; @@ -88,9 +90,8 @@ 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 (":"). For standard OOv2 they filter all proposals. - // For Managed OOv2 they filter only non-resolved proposals; resolved disputes always follow normal settlement. - // The include list takes precedence over the exclude list. Both undefined is invalid for ManagedOptimisticOracleV2. + // Proposal event ids (":"). Standard OOv2 supports include/exclude filters on all proposals. + // Managed OOv2 only supports an include list for non-resolved proposals; resolved disputes follow normal settlement. settleIncludeList?: Set; settleExcludeList?: Set; } diff --git a/packages/monitor-v2/test/OptimisticOracleV2Bot.ts b/packages/monitor-v2/test/OptimisticOracleV2Bot.ts index c5a649bbe4..d8233bccde 100644 --- a/packages/monitor-v2/test/OptimisticOracleV2Bot.ts +++ b/packages/monitor-v2/test/OptimisticOracleV2Bot.ts @@ -491,7 +491,43 @@ describe("OptimisticOracleV2Bot", function () { assert.isAbove(settledIndex, -1, "Disputed request should be settled when settleOnlyDisputed is true"); }); - for (const listMode of ["empty include", "invalid include", "matching exclude"] as const) { + it("Keeps exclude list behavior for standard OOv2", 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 standard OOv2 proposal should not be settled"); + + const filterLog = getLast( + spy.getCalls().filter((call) => call.lastArg?.message === "Applied include/exclude proposal filter"), + "Expected standard OOv2 exclude filter log" + ).lastArg; + assert.equal(filterLog.mode, "exclude"); + assert.deepEqual(filterLog.skippedIds, [getProposalEventId(proposeReceipt)]); + }); + + for (const listMode of ["empty include", "invalid include"] as const) { it(`Managed OOv2 settles disputed requests when using the ${listMode} list`, async function () { await ( await optimisticOracleV2.requestPrice( @@ -503,7 +539,7 @@ describe("OptimisticOracleV2Bot", function () { ) ).wait(); - const proposeReceipt = await ( + await ( await optimisticOracleV2 .connect(proposer) .proposePrice( @@ -529,22 +565,16 @@ describe("OptimisticOracleV2Bot", function () { const { spy, logger } = makeSpyLogger(); const params = await createParams("ManagedOptimisticOracleV2", optimisticOracleV2.address); - const id = getProposalEventId(proposeReceipt); if (listMode === "empty include") { params.settleIncludeList = new Set(); - params.settleExcludeList = undefined; - } else if (listMode === "invalid include") { - params.settleIncludeList = new Set([proposalEventId(`0x${"0".repeat(64)}`, 0)]); - params.settleExcludeList = undefined; } else { - params.settleIncludeList = undefined; - params.settleExcludeList = new Set([id]); + 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, 1, "Settlement lists must not block normal disputed settlement"); + assert.equal(settlementLogs.length, 1, "Include lists must not block normal disputed settlement"); if (listMode === "invalid include") { assert.isTrue( @@ -555,46 +585,6 @@ describe("OptimisticOracleV2Bot", function () { }); } - it("Skips non-disputed 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 skipLog = getLast( - spy - .getCalls() - .filter( - (call) => call.lastArg?.message === "Skipping non-resolved Managed OOv2 request outside settlement lists" - ), - "Expected settlement list skip log" - ).lastArg; - assert.equal(skipLog.proposalEventId, getProposalEventId(proposeReceipt)); - assert.equal(skipLog.mode, "exclude"); - }); - it("Uses the include list for non-disputed Managed OOv2 proposals", async function () { await ( await optimisticOracleV2.requestPrice(defaultOptimisticOracleV2Identifier, 0, ancillaryData, bondToken.address, 0) @@ -629,12 +619,12 @@ describe("OptimisticOracleV2Bot", function () { spy .getCalls() .filter( - (call) => call.lastArg?.message === "Skipping non-resolved Managed OOv2 request outside settlement lists" + (call) => + call.lastArg?.message === "Skipping non-resolved Managed OOv2 request outside settlement include list" ), - "Expected settlement list skip log" + "Expected include list skip log" ).lastArg; assert.equal(skipLog.proposalEventId, getProposalEventId(proposeReceipt)); - assert.equal(skipLog.mode, "include"); } // An include list containing the proposal: it settles. @@ -654,8 +644,7 @@ describe("OptimisticOracleV2Bot", function () { } }); - it("Accepts explicit empty include/exclude lists", async function () { - // Templated deployments commonly render optional list env vars as "[]"; this must not throw. + it("Parses explicit empty proposal lists", async function () { const includeList = parseProposalIdList("[]", "SETTLE_INCLUDE_LIST"); assert.instanceOf(includeList, Set); assert.equal(includeList?.size, 0); @@ -674,24 +663,39 @@ describe("OptimisticOracleV2Bot", function () { 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 () { + it("Rejects exclude lists for Managed OOv2", async function () { + for (const value of ["", "[]", JSON.stringify([`0x${"0".repeat(64)}:0`])]) { + assert.throws( + () => + parseSettleProposalIdLists({ SETTLE_EXCLUDE_LIST: value } as NodeJS.ProcessEnv, "ManagedOptimisticOracleV2"), + /SETTLE_EXCLUDE_LIST is not supported for ManagedOptimisticOracleV2/ + ); + } + + const { logger } = makeSpyLogger(); + const params = await createParams("ManagedOptimisticOracleV2", optimisticOracleV2.address); + params.settleExcludeList = new Set(); + + let error: unknown; + try { + await settleRequests(logger, params, gasEstimator); + } catch (err) { + error = err; + } + + assert.instanceOf(error, Error); + assert.match((error as Error).message, /SETTLE_EXCLUDE_LIST is not supported for ManagedOptimisticOracleV2/); + }); + + it("Rejects Managed OOv2 settlement params without an include list", async function () { const { logger } = makeSpyLogger(); const params = await createParams("ManagedOptimisticOracleV2", optimisticOracleV2.address); params.settleIncludeList = undefined; - params.settleExcludeList = undefined; let error: unknown; try { @@ -701,7 +705,7 @@ describe("OptimisticOracleV2Bot", function () { } assert.instanceOf(error, Error); - assert.match((error as Error).message, /Managed OOv2 settlement requires an include or exclude list/); + assert.match((error as Error).message, /Managed OOv2 settlement requires an include list/); }); it("Rejects include/exclude lists for non-OOv2 oracle types", async function () { @@ -717,7 +721,6 @@ describe("OptimisticOracleV2Bot", function () { /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; diff --git a/packages/monitor-v2/test/helpers/monitoring.ts b/packages/monitor-v2/test/helpers/monitoring.ts index 94d5814d6e..67321ffbe0 100644 --- a/packages/monitor-v2/test/helpers/monitoring.ts +++ b/packages/monitor-v2/test/helpers/monitoring.ts @@ -43,7 +43,7 @@ export async function makeMonitoringParamsOO( contractAddress, settleBatchSize: 1, settleMinProposalAgeSeconds: 0, - ...(oracleType === "ManagedOptimisticOracleV2" ? { settleExcludeList: new Set() } : {}), + ...(oracleType === "ManagedOptimisticOracleV2" ? { settleIncludeList: new Set() } : {}), }; } From 9789cfe3ba0588a4dc8eae8d348c219219badf89 Mon Sep 17 00:00:00 2001 From: Pablo Maldonado Date: Mon, 27 Jul 2026 11:29:01 +0100 Subject: [PATCH 4/6] fix(bot-oo): remove settlement exclude lists Signed-off-by: Pablo Maldonado --- packages/monitor-v2/src/bot-oo/README.md | 3 +- .../src/bot-oo/SettleOOv2Requests.ts | 30 ++--- packages/monitor-v2/src/bot-oo/common.ts | 44 +++---- packages/monitor-v2/src/bot-oo/requestKey.ts | 2 +- .../monitor-v2/test/OptimisticOracleV2Bot.ts | 107 ++++-------------- 5 files changed, 53 insertions(+), 133 deletions(-) diff --git a/packages/monitor-v2/src/bot-oo/README.md b/packages/monitor-v2/src/bot-oo/README.md index 849d7466bd..f99aa61f31 100644 --- a/packages/monitor-v2/src/bot-oo/README.md +++ b/packages/monitor-v2/src/bot-oo/README.md @@ -37,8 +37,7 @@ 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`, standard `OptimisticOracleV2` only settles resolved disputes (`false` by default). Managed OOv2 always preserves normal resolved-dispute settlement and uses its include list for other requests. 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. For standard OOv2 the bot settles **only** these proposals. For Managed OOv2 this is the only supported settlement list: listed proposals are fetched directly and added to the normal resolved-dispute candidates. Managed OOv2 defaults to an empty include list, which prevents all non-resolved proposals from settling while leaving resolved disputes unaffected. Example: `["0xabc...def:5"]`. -- `SETTLE_EXCLUDE_LIST`: JSON array of `":"` proposal identifiers to **skip** in standard OOv2. Ignored there when `SETTLE_INCLUDE_LIST` is set. Managed OOv2 does not support this setting and throws at startup whenever it is configured, including as an empty array (`[]`). +- `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. For standard OOv2 the bot settles **only** these proposals. For Managed OOv2, listed proposals are fetched directly and added to the normal resolved-dispute candidates. Managed OOv2 defaults to an empty include list, which prevents all non-resolved proposals from settling while leaving resolved disputes unaffected. OOv2 contract types only — setting it for another `ORACLE_TYPE` throws at startup. Example: `["0xabc...def:5"]`. ## Behavior diff --git a/packages/monitor-v2/src/bot-oo/SettleOOv2Requests.ts b/packages/monitor-v2/src/bot-oo/SettleOOv2Requests.ts index 0d431e1932..5259825f1c 100644 --- a/packages/monitor-v2/src/bot-oo/SettleOOv2Requests.ts +++ b/packages/monitor-v2/src/bot-oo/SettleOOv2Requests.ts @@ -21,37 +21,32 @@ function chunk(arr: T[], size: number): T[][] { return chunks; } -// Applies the standard OOv2 include/exclude proposal lists. Managed OOv2 applies them after checking request state so -// resolved disputes preserve their normal settlement behavior. -function filterByIncludeExclude( +// Applies the standard OOv2 include list. Managed OOv2 applies it after checking request state so resolved disputes +// preserve their normal settlement behavior. +function filterByIncludeList( logger: typeof Logger, params: MonitoringParams, requests: ProposePriceEvent[] ): ProposePriceEvent[] { - const { settleIncludeList, settleExcludeList } = params; - if (!settleIncludeList && !settleExcludeList) return requests; + const { settleIncludeList } = params; + if (!settleIncludeList) 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); + if (settleIncludeList.has(id)) 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, + message: "Applied proposal include filter", + listSize: settleIncludeList.size, kept: kept.length, skipped, - ...(settleIncludeList ? {} : { skippedIds }), }); return kept; @@ -87,11 +82,8 @@ export async function settleOOv2Requests( params: MonitoringParams, gasEstimator: GasEstimator ): Promise { - if (params.oracleType === "ManagedOptimisticOracleV2") { - if (params.settleExcludeList !== undefined) - throw new Error("SETTLE_EXCLUDE_LIST is not supported for ManagedOptimisticOracleV2; use SETTLE_INCLUDE_LIST"); - if (!params.settleIncludeList) throw new Error("Managed OOv2 settlement requires an include list"); - } + if (params.oracleType === "ManagedOptimisticOracleV2" && !params.settleIncludeList) + throw new Error("Managed OOv2 settlement requires an include list"); const oo = await getContractInstanceWithProvider( "OptimisticOracleV2", @@ -198,7 +190,7 @@ export async function settleOOv2Requests( const requestsToSettle = params.oracleType === "ManagedOptimisticOracleV2" ? unsettledRequests - : filterByIncludeExclude(logger, params, unsettledRequests); + : filterByIncludeList(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 8607989421..685a602699 100644 --- a/packages/monitor-v2/src/bot-oo/common.ts +++ b/packages/monitor-v2/src/bot-oo/common.ts @@ -47,34 +47,22 @@ export function parseProposalIdList(value: string | undefined, envName: string): return new Set(ids); } -// Parses SETTLE_INCLUDE_LIST/SETTLE_EXCLUDE_LIST for OOv2 contracts. Managed OOv2 only supports an include list for -// non-resolved requests; normal resolved-dispute settlement is always preserved. -export function parseSettleProposalIdLists( - env: NodeJS.ProcessEnv, - oracleType: OracleType -): { settleIncludeList?: Set; settleExcludeList?: Set } { - const configuredIncludeList = parseProposalIdList(env.SETTLE_INCLUDE_LIST, "SETTLE_INCLUDE_LIST"); - if (oracleType === "ManagedOptimisticOracleV2" && env.SETTLE_EXCLUDE_LIST !== undefined) - throw new Error("SETTLE_EXCLUDE_LIST is not supported for ManagedOptimisticOracleV2; use SETTLE_INCLUDE_LIST"); +// Parses SETTLE_INCLUDE_LIST for OOv2 contracts. For Managed OOv2 it governs non-resolved requests only; normal +// resolved-dispute settlement is always preserved. +export function parseSettleIncludeList(env: NodeJS.ProcessEnv, oracleType: OracleType): Set | undefined { + // Fail fast on the removed setting so stale deployments cannot silently fall back to settling every proposal. + if (env.SETTLE_EXCLUDE_LIST !== undefined) + throw new Error("SETTLE_EXCLUDE_LIST is not supported; use 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" - ); + const configuredIncludeList = parseProposalIdList(env.SETTLE_INCLUDE_LIST, "SETTLE_INCLUDE_LIST"); + if (configuredIncludeList && oracleType !== "OptimisticOracleV2" && oracleType !== "ManagedOptimisticOracleV2") + throw new Error("SETTLE_INCLUDE_LIST is only supported for OptimisticOracleV2 and ManagedOptimisticOracleV2"); // Default Managed OOv2 settlement to an empty include list so missing configuration cannot settle non-resolved // proposals. - const settleIncludeList = - oracleType === "ManagedOptimisticOracleV2" && configuredIncludeList === undefined - ? new Set() - : configuredIncludeList; - - return { settleIncludeList, settleExcludeList }; + return oracleType === "ManagedOptimisticOracleV2" && configuredIncludeList === undefined + ? new Set() + : configuredIncludeList; } export interface BotModes { @@ -90,10 +78,9 @@ 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 - // Proposal event ids (":"). Standard OOv2 supports include/exclude filters on all proposals. - // Managed OOv2 only supports an include list for non-resolved proposals; resolved disputes follow normal settlement. + // Proposal event ids (":"). Standard OOv2 applies the include filter to all proposals. Managed + // OOv2 applies it only to non-resolved proposals; resolved disputes follow normal settlement. settleIncludeList?: Set; - settleExcludeList?: Set; } export const initMonitoringParams = async (env: NodeJS.ProcessEnv): Promise => { @@ -136,7 +123,7 @@ export const initMonitoringParams = async (env: NodeJS.ProcessEnv): Promise ); // 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). +// identifier used by the settlement include list (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 d8233bccde..f1dd8cd216 100644 --- a/packages/monitor-v2/test/OptimisticOracleV2Bot.ts +++ b/packages/monitor-v2/test/OptimisticOracleV2Bot.ts @@ -8,7 +8,7 @@ import { import { spyLogIncludes, spyLogLevel, GasEstimator } from "@uma/financial-templates-lib"; import { BlockFinder } from "@uma/sdk"; import { assert } from "chai"; -import { OracleType, parseProposalIdList, parseSettleProposalIdLists } from "../src/bot-oo/common"; +import { OracleType, parseProposalIdList, parseSettleIncludeList } from "../src/bot-oo/common"; import { proposalEventId } from "../src/bot-oo/requestKey"; import { settleRequests } from "../src/bot-oo/SettleRequests"; import { defaultLiveness, defaultOptimisticOracleV2Identifier } from "./constants"; @@ -491,42 +491,6 @@ describe("OptimisticOracleV2Bot", function () { assert.isAbove(settledIndex, -1, "Disputed request should be settled when settleOnlyDisputed is true"); }); - it("Keeps exclude list behavior for standard OOv2", 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 standard OOv2 proposal should not be settled"); - - const filterLog = getLast( - spy.getCalls().filter((call) => call.lastArg?.message === "Applied include/exclude proposal filter"), - "Expected standard OOv2 exclude filter log" - ).lastArg; - assert.equal(filterLog.mode, "exclude"); - assert.deepEqual(filterLog.skippedIds, [getProposalEventId(proposeReceipt)]); - }); - for (const listMode of ["empty include", "invalid include"] as const) { it(`Managed OOv2 settles disputed requests when using the ${listMode} list`, async function () { await ( @@ -644,52 +608,36 @@ describe("OptimisticOracleV2Bot", function () { } }); - it("Parses explicit empty proposal lists", async function () { + it("Parses an explicit empty proposal include list", async function () { 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" - ); + const settleIncludeList = parseSettleIncludeList({} as NodeJS.ProcessEnv, "ManagedOptimisticOracleV2"); assert.instanceOf(settleIncludeList, Set); assert.equal(settleIncludeList?.size, 0); - assert.isUndefined(settleExcludeList); - const standardOOv2 = parseSettleProposalIdLists({} as NodeJS.ProcessEnv, "OptimisticOracleV2"); - assert.isUndefined(standardOOv2.settleIncludeList); - assert.isUndefined(standardOOv2.settleExcludeList); + const standardOOv2 = parseSettleIncludeList({} as NodeJS.ProcessEnv, "OptimisticOracleV2"); + assert.isUndefined(standardOOv2); }); - it("Rejects exclude lists for Managed OOv2", async function () { + it("Rejects the removed exclude list setting for every oracle type", async function () { + const oracleTypes: OracleType[] = [ + "OptimisticOracle", + "SkinnyOptimisticOracle", + "OptimisticOracleV2", + "ManagedOptimisticOracleV2", + ]; for (const value of ["", "[]", JSON.stringify([`0x${"0".repeat(64)}:0`])]) { - assert.throws( - () => - parseSettleProposalIdLists({ SETTLE_EXCLUDE_LIST: value } as NodeJS.ProcessEnv, "ManagedOptimisticOracleV2"), - /SETTLE_EXCLUDE_LIST is not supported for ManagedOptimisticOracleV2/ - ); - } - - const { logger } = makeSpyLogger(); - const params = await createParams("ManagedOptimisticOracleV2", optimisticOracleV2.address); - params.settleExcludeList = new Set(); - - let error: unknown; - try { - await settleRequests(logger, params, gasEstimator); - } catch (err) { - error = err; + for (const oracleType of oracleTypes) { + assert.throws( + () => parseSettleIncludeList({ SETTLE_EXCLUDE_LIST: value } as NodeJS.ProcessEnv, oracleType), + /SETTLE_EXCLUDE_LIST is not supported/ + ); + } } - - assert.instanceOf(error, Error); - assert.match((error as Error).message, /SETTLE_EXCLUDE_LIST is not supported for ManagedOptimisticOracleV2/); }); it("Rejects Managed OOv2 settlement params without an include list", async function () { @@ -708,28 +656,23 @@ describe("OptimisticOracleV2Bot", function () { assert.match((error as Error).message, /Managed OOv2 settlement requires an include 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; + it("Rejects include lists for non-OOv2 oracle types", async function () { + const env = { SETTLE_INCLUDE_LIST: JSON.stringify([`0x${"0".repeat(64)}:0`]) } as NodeJS.ProcessEnv; assert.throws( - () => parseSettleProposalIdLists(env, "OptimisticOracle"), + () => parseSettleIncludeList(env, "OptimisticOracle"), /only supported for OptimisticOracleV2 and ManagedOptimisticOracleV2/ ); assert.throws( - () => parseSettleProposalIdLists(env, "SkinnyOptimisticOracle"), + () => parseSettleIncludeList(env, "SkinnyOptimisticOracle"), /only supported for OptimisticOracleV2 and ManagedOptimisticOracleV2/ ); - assert.doesNotThrow(() => parseSettleProposalIdLists(env, "OptimisticOracleV2")); - - // 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")); + assert.doesNotThrow(() => parseSettleIncludeList(env, "OptimisticOracleV2")); + assert.doesNotThrow(() => parseSettleIncludeList(env, "ManagedOptimisticOracleV2")); // 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"), + () => parseSettleIncludeList(emptyIncludeEnv, "OptimisticOracle"), /only supported for OptimisticOracleV2 and ManagedOptimisticOracleV2/ ); }); From e743eb795b43d2fadb2a0c8a2df5b8a8e3a934d2 Mon Sep 17 00:00:00 2001 From: Pablo Maldonado Date: Mon, 27 Jul 2026 11:52:37 +0100 Subject: [PATCH 5/6] fix(bot-oo): restrict managed settlement to includes Signed-off-by: Pablo Maldonado --- packages/monitor-v2/src/bot-oo/README.md | 4 +- .../src/bot-oo/SettleOOv2Requests.ts | 143 +++++++----------- packages/monitor-v2/src/bot-oo/common.ts | 10 +- .../monitor-v2/test/OptimisticOracleV2Bot.ts | 112 +++++++------- 4 files changed, 115 insertions(+), 154 deletions(-) diff --git a/packages/monitor-v2/src/bot-oo/README.md b/packages/monitor-v2/src/bot-oo/README.md index f99aa61f31..bf3b61d587 100644 --- a/packages/monitor-v2/src/bot-oo/README.md +++ b/packages/monitor-v2/src/bot-oo/README.md @@ -36,8 +36,8 @@ node ./packages/monitor-v2/dist/bot-oo/index.js - `SETTLE_DELAY`: Lookback period in seconds to detect settleable requests (default `300`). - `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`, standard `OptimisticOracleV2` only settles resolved disputes (`false` by default). Managed OOv2 always preserves normal resolved-dispute settlement and uses its include list for other requests. 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. For standard OOv2 the bot settles **only** these proposals. For Managed OOv2, listed proposals are fetched directly and added to the normal resolved-dispute candidates. Managed OOv2 defaults to an empty include list, which prevents all non-resolved proposals from settling while leaving resolved disputes unaffected. OOv2 contract types only — setting it for another `ORACLE_TYPE` throws at startup. Example: `["0xabc...def:5"]`. +- `SETTLE_ONLY_DISPUTED`: When `true`, standard `OptimisticOracleV2` only settles resolved disputes (`false` by default). Ignored for every other oracle type. +- `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. For standard OOv2 the bot settles **only** these proposals. Managed OOv2 fetches and processes only the listed proposals, regardless of dispute status, and defaults to an empty list that settles nothing. OOv2 contract types only — setting it for another `ORACLE_TYPE` throws at startup. Example: `["0xabc...def:5"]`. ## Behavior diff --git a/packages/monitor-v2/src/bot-oo/SettleOOv2Requests.ts b/packages/monitor-v2/src/bot-oo/SettleOOv2Requests.ts index 5259825f1c..40f5bfe148 100644 --- a/packages/monitor-v2/src/bot-oo/SettleOOv2Requests.ts +++ b/packages/monitor-v2/src/bot-oo/SettleOOv2Requests.ts @@ -21,8 +21,7 @@ function chunk(arr: T[], size: number): T[][] { return chunks; } -// Applies the standard OOv2 include list. Managed OOv2 applies it after checking request state so resolved disputes -// preserve their normal settlement behavior. +// Applies the include list to the discovered proposals. function filterByIncludeList( logger: typeof Logger, params: MonitoringParams, @@ -82,8 +81,11 @@ export async function settleOOv2Requests( params: MonitoringParams, gasEstimator: GasEstimator ): Promise { - if (params.oracleType === "ManagedOptimisticOracleV2" && !params.settleIncludeList) - throw new Error("Managed OOv2 settlement requires an include list"); + let managedIncludeList: Set | undefined; + if (params.oracleType === "ManagedOptimisticOracleV2") { + if (!params.settleIncludeList) throw new Error("Managed OOv2 settlement requires an include list"); + managedIncludeList = params.settleIncludeList; + } const oo = await getContractInstanceWithProvider( "OptimisticOracleV2", @@ -91,95 +93,79 @@ export async function settleOOv2Requests( params.contractAddress ); - const searchConfig = await computeEventSearch( - params.provider, - params.blockFinder, - params.timeLookback, - params.maxBlockLookBack - ); - - logger.debug({ - at: "OOv2Bot", - message: "Querying ProposePrice events", - fromBlock: searchConfig.fromBlock, - toBlock: searchConfig.toBlock, - maxBlockLookBack: searchConfig.maxBlockLookBack, - }); - const proposalsStartedAt = Date.now(); let proposals: ProposePriceEvent[]; - try { - proposals = await paginatedEventQuery(oo, oo.filters.ProposePrice(), searchConfig); + let settlements: SettleEvent[]; + if (managedIncludeList) { + proposals = await getManagedIncludedProposals(oo, managedIncludeList); + settlements = []; logger.debug({ at: "OOv2Bot", - message: "Queried ProposePrice events", + message: "Queried included ProposePrice events", + requested: managedIncludeList.size, count: proposals.length, - elapsedMs: Date.now() - proposalsStartedAt, }); - } catch (error) { - logger.error({ + } else { + const searchConfig = await computeEventSearch( + params.provider, + params.blockFinder, + params.timeLookback, + params.maxBlockLookBack + ); + + logger.debug({ at: "OOv2Bot", - message: "Failed querying ProposePrice events", + message: "Querying ProposePrice events", fromBlock: searchConfig.fromBlock, toBlock: searchConfig.toBlock, maxBlockLookBack: searchConfig.maxBlockLookBack, - ...getSettleTxErrorLogFields(error), }); - throw error; - } + const proposalsStartedAt = Date.now(); + try { + proposals = await paginatedEventQuery(oo, oo.filters.ProposePrice(), searchConfig); + logger.debug({ + at: "OOv2Bot", + message: "Queried ProposePrice events", + count: proposals.length, + elapsedMs: Date.now() - proposalsStartedAt, + }); + } catch (error) { + logger.error({ + at: "OOv2Bot", + message: "Failed querying ProposePrice events", + fromBlock: searchConfig.fromBlock, + toBlock: searchConfig.toBlock, + maxBlockLookBack: searchConfig.maxBlockLookBack, + ...getSettleTxErrorLogFields(error), + }); + throw error; + } - logger.debug({ - at: "OOv2Bot", - message: "Querying Settle events", - fromBlock: searchConfig.fromBlock, - toBlock: searchConfig.toBlock, - maxBlockLookBack: searchConfig.maxBlockLookBack, - }); - const settlementsStartedAt = Date.now(); - let settlements: SettleEvent[]; - try { - settlements = await paginatedEventQuery(oo, oo.filters.Settle(), searchConfig); logger.debug({ at: "OOv2Bot", - message: "Queried Settle events", - count: settlements.length, - elapsedMs: Date.now() - settlementsStartedAt, - }); - } catch (error) { - logger.error({ - at: "OOv2Bot", - message: "Failed querying Settle events", + message: "Querying Settle events", fromBlock: searchConfig.fromBlock, toBlock: searchConfig.toBlock, maxBlockLookBack: searchConfig.maxBlockLookBack, - ...getSettleTxErrorLogFields(error), }); - throw error; - } - - if (params.oracleType === "ManagedOptimisticOracleV2" && params.settleIncludeList) { + const settlementsStartedAt = Date.now(); try { - const includedProposals = await getManagedIncludedProposals(oo, params.settleIncludeList); - const proposalsById = new Map( - proposals.map((proposal) => [proposalEventId(proposal.transactionHash, proposal.logIndex), proposal] as const) - ); - for (const proposal of includedProposals) { - proposalsById.set(proposalEventId(proposal.transactionHash, proposal.logIndex), proposal); - } - proposals = [...proposalsById.values()]; + settlements = await paginatedEventQuery(oo, oo.filters.Settle(), searchConfig); logger.debug({ at: "OOv2Bot", - message: "Queried included ProposePrice events", - requested: params.settleIncludeList.size, - count: includedProposals.length, + message: "Queried Settle events", + count: settlements.length, + elapsedMs: Date.now() - settlementsStartedAt, }); } catch (error) { - // Manual candidates fail closed, but their lookup must not block historical disputed settlements. logger.error({ at: "OOv2Bot", - message: "Failed querying included ProposePrice events", - requested: params.settleIncludeList.size, + message: "Failed querying Settle events", + fromBlock: searchConfig.fromBlock, + toBlock: searchConfig.toBlock, + maxBlockLookBack: searchConfig.maxBlockLookBack, ...getSettleTxErrorLogFields(error), }); + throw error; } } @@ -187,10 +173,7 @@ export async function settleOOv2Requests( const unsettledRequests = proposals.filter((e) => !settledKeys.has(requestKey(e.args))); - const requestsToSettle = - params.oracleType === "ManagedOptimisticOracleV2" - ? unsettledRequests - : filterByIncludeList(logger, params, unsettledRequests); + const requestsToSettle = filterByIncludeList(logger, params, unsettledRequests); const requestsToSettleTxCount = params.settleBatchSize > 1 ? Math.ceil(requestsToSettle.length / params.settleBatchSize) : requestsToSettle.length; @@ -246,27 +229,15 @@ export async function settleOOv2Requests( } } - // Managed OOv2 always preserves normal disputed settlement; its include list governs non-resolved requests. - if (params.oracleType === "ManagedOptimisticOracleV2" || params.botModes.settleOnlyDisputed) { + // Standard OOv2 can optionally limit settlement to disputes resolved by the DVM. + if (params.oracleType === "OptimisticOracleV2" && params.botModes.settleOnlyDisputed) { const state = await oo.getState( req.args.requester, req.args.identifier, req.args.timestamp, req.args.ancillaryData ); - if (params.oracleType === "ManagedOptimisticOracleV2" && state !== STATE_RESOLVED) { - const id = proposalEventId(req.transactionHash, req.logIndex); - if (!params.settleIncludeList?.has(id)) { - logger.debug({ - at: "OOv2Bot", - message: "Skipping non-resolved Managed OOv2 request outside settlement include list", - requestKey: requestKey(req.args), - proposalEventId: id, - state, - }); - return null; - } - } else if (params.oracleType !== "ManagedOptimisticOracleV2" && state !== STATE_RESOLVED) { + if (state !== STATE_RESOLVED) { logger.debug({ at: "OOv2Bot", message: "Skipping non-disputed request (settleOnlyDisputed)", diff --git a/packages/monitor-v2/src/bot-oo/common.ts b/packages/monitor-v2/src/bot-oo/common.ts index 685a602699..babcfb735c 100644 --- a/packages/monitor-v2/src/bot-oo/common.ts +++ b/packages/monitor-v2/src/bot-oo/common.ts @@ -47,8 +47,7 @@ export function parseProposalIdList(value: string | undefined, envName: string): return new Set(ids); } -// Parses SETTLE_INCLUDE_LIST for OOv2 contracts. For Managed OOv2 it governs non-resolved requests only; normal -// resolved-dispute settlement is always preserved. +// Parses SETTLE_INCLUDE_LIST for OOv2 contracts. Managed OOv2 uses it as the complete settlement candidate set. export function parseSettleIncludeList(env: NodeJS.ProcessEnv, oracleType: OracleType): Set | undefined { // Fail fast on the removed setting so stale deployments cannot silently fall back to settling every proposal. if (env.SETTLE_EXCLUDE_LIST !== undefined) @@ -58,8 +57,7 @@ export function parseSettleIncludeList(env: NodeJS.ProcessEnv, oracleType: Oracl if (configuredIncludeList && oracleType !== "OptimisticOracleV2" && oracleType !== "ManagedOptimisticOracleV2") throw new Error("SETTLE_INCLUDE_LIST is only supported for OptimisticOracleV2 and ManagedOptimisticOracleV2"); - // Default Managed OOv2 settlement to an empty include list so missing configuration cannot settle non-resolved - // proposals. + // Default Managed OOv2 settlement to an empty include list so missing configuration cannot settle any proposals. return oracleType === "ManagedOptimisticOracleV2" && configuredIncludeList === undefined ? new Set() : configuredIncludeList; @@ -78,8 +76,8 @@ 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 - // Proposal event ids (":"). Standard OOv2 applies the include filter to all proposals. Managed - // OOv2 applies it only to non-resolved proposals; resolved disputes follow normal settlement. + // Proposal event ids (":"). Standard OOv2 filters its discovered proposals with this list. + // Managed OOv2 fetches and processes only proposals in this list. settleIncludeList?: Set; } diff --git a/packages/monitor-v2/test/OptimisticOracleV2Bot.ts b/packages/monitor-v2/test/OptimisticOracleV2Bot.ts index f1dd8cd216..ea989a4598 100644 --- a/packages/monitor-v2/test/OptimisticOracleV2Bot.ts +++ b/packages/monitor-v2/test/OptimisticOracleV2Bot.ts @@ -491,63 +491,67 @@ describe("OptimisticOracleV2Bot", function () { assert.isAbove(settledIndex, -1, "Disputed request should be settled when settleOnlyDisputed is true"); }); - for (const listMode of ["empty include", "invalid include"] as const) { - it(`Managed OOv2 settles disputed requests when using the ${listMode} list`, async function () { - await ( - await optimisticOracleV2.requestPrice( + it("Managed OOv2 only settles disputed requests 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, - bondToken.address, - 0 + ethers.utils.parseEther("1") ) - ).wait(); + ).wait(); - await ( - await optimisticOracleV2 - .connect(proposer) - .proposePrice( - await requester.getAddress(), - defaultOptimisticOracleV2Identifier, - 0, - ancillaryData, - ethers.utils.parseEther("1") - ) - ).wait(); + await ( + await optimisticOracleV2 + .connect(disputer) + .disputePrice(await requester.getAddress(), defaultOptimisticOracleV2Identifier, 0, ancillaryData) + ).wait(); - await ( - await optimisticOracleV2 - .connect(disputer) - .disputePrice(await requester.getAddress(), defaultOptimisticOracleV2Identifier, 0, ancillaryData) - ).wait(); + const pending = await mockOracle.getPendingQueries(); + const last = getLast(pending, "Expected a pending DVM query"); + await ( + await mockOracle.pushPrice(last.identifier, last.time, last.ancillaryData, ethers.utils.parseEther("1")) + ).wait(); - const pending = await mockOracle.getPendingQueries(); - const last = getLast(pending, "Expected a pending DVM query"); - await ( - await mockOracle.pushPrice(last.identifier, last.time, last.ancillaryData, ethers.utils.parseEther("1")) - ).wait(); + const { spy, logger } = makeSpyLogger(); + const params = await createParams("ManagedOptimisticOracleV2", optimisticOracleV2.address); + params.settleIncludeList = new Set(); + await gasEstimator.update(); + await settleRequests(logger, params, gasEstimator); - const { spy, logger } = makeSpyLogger(); - const params = await createParams("ManagedOptimisticOracleV2", optimisticOracleV2.address); - if (listMode === "empty include") { - params.settleIncludeList = new Set(); - } else { - params.settleIncludeList = new Set([proposalEventId(`0x${"0".repeat(64)}`, 0)]); - } - await gasEstimator.update(); - await settleRequests(logger, params, gasEstimator); + let settlementLogs = spy.getCalls().filter((call) => call.lastArg?.message === "Price Request Settled ✅"); + assert.equal(settlementLogs.length, 0, "A disputed request outside the include list must not be settled"); - const settlementLogs = spy.getCalls().filter((call) => call.lastArg?.message === "Price Request Settled ✅"); - assert.equal(settlementLogs.length, 1, "Include lists must not block normal disputed settlement"); + spy.resetHistory(); + params.settleIncludeList = new Set([getProposalEventId(proposeReceipt)]); + await settleRequests(logger, params, gasEstimator); - if (listMode === "invalid include") { - assert.isTrue( - spy.getCalls().some((call) => call.lastArg?.message === "Failed querying included ProposePrice events"), - "Invalid direct includes should be logged without blocking disputed settlement" - ); - } - }); - } + settlementLogs = spy.getCalls().filter((call) => call.lastArg?.message === "Price Request Settled ✅"); + assert.equal(settlementLogs.length, 1, "An included disputed request should be settled"); + }); + + it("Fails closed when a Managed OOv2 include cannot be loaded", async function () { + const { logger } = makeSpyLogger(); + const params = await createParams("ManagedOptimisticOracleV2", optimisticOracleV2.address); + params.settleIncludeList = new Set([proposalEventId(`0x${"0".repeat(64)}`, 0)]); + + let error: unknown; + try { + await settleRequests(logger, params, gasEstimator); + } catch (err) { + error = err; + } + + assert.instanceOf(error, Error); + assert.match((error as Error).message, /Could not find included ProposePrice event/); + }); it("Uses the include list for non-disputed Managed OOv2 proposals", async function () { await ( @@ -578,17 +582,6 @@ describe("OptimisticOracleV2Bot", function () { 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 skipLog = getLast( - spy - .getCalls() - .filter( - (call) => - call.lastArg?.message === "Skipping non-resolved Managed OOv2 request outside settlement include list" - ), - "Expected include list skip log" - ).lastArg; - assert.equal(skipLog.proposalEventId, getProposalEventId(proposeReceipt)); } // An include list containing the proposal: it settles. @@ -596,8 +589,7 @@ describe("OptimisticOracleV2Bot", function () { const { spy, logger } = makeSpyLogger(); const params = await createParams("ManagedOptimisticOracleV2", optimisticOracleV2.address); params.settleIncludeList = new Set([getProposalEventId(proposeReceipt)]); - params.botModes.settleOnlyDisputed = true; - // Exclude the proposal from the historical event range to exercise the direct include-list lookup. + // The direct include-list lookup does not depend on the historical event range. params.timeLookback = 0; params.blockFinder = new BlockFinder(params.provider.getBlock.bind(params.provider), undefined, params.chainId); await gasEstimator.update(); From f2896b2e29a685b4e9a98656fd48a9853d175108 Mon Sep 17 00:00:00 2001 From: Pablo Maldonado Date: Mon, 27 Jul 2026 11:55:36 +0100 Subject: [PATCH 6/6] Revert "fix(bot-oo): restrict managed settlement to includes" This reverts commit e743eb795b43d2fadb2a0c8a2df5b8a8e3a934d2. Signed-off-by: Pablo Maldonado --- packages/monitor-v2/src/bot-oo/README.md | 4 +- .../src/bot-oo/SettleOOv2Requests.ts | 143 +++++++++++------- packages/monitor-v2/src/bot-oo/common.ts | 10 +- .../monitor-v2/test/OptimisticOracleV2Bot.ts | 112 +++++++------- 4 files changed, 154 insertions(+), 115 deletions(-) diff --git a/packages/monitor-v2/src/bot-oo/README.md b/packages/monitor-v2/src/bot-oo/README.md index bf3b61d587..f99aa61f31 100644 --- a/packages/monitor-v2/src/bot-oo/README.md +++ b/packages/monitor-v2/src/bot-oo/README.md @@ -36,8 +36,8 @@ node ./packages/monitor-v2/dist/bot-oo/index.js - `SETTLE_DELAY`: Lookback period in seconds to detect settleable requests (default `300`). - `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`, standard `OptimisticOracleV2` only settles resolved disputes (`false` by default). Ignored for every other oracle type. -- `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. For standard OOv2 the bot settles **only** these proposals. Managed OOv2 fetches and processes only the listed proposals, regardless of dispute status, and defaults to an empty list that settles nothing. OOv2 contract types only — setting it for another `ORACLE_TYPE` throws at startup. Example: `["0xabc...def:5"]`. +- `SETTLE_ONLY_DISPUTED`: When `true`, standard `OptimisticOracleV2` only settles resolved disputes (`false` by default). Managed OOv2 always preserves normal resolved-dispute settlement and uses its include list for other requests. 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. For standard OOv2 the bot settles **only** these proposals. For Managed OOv2, listed proposals are fetched directly and added to the normal resolved-dispute candidates. Managed OOv2 defaults to an empty include list, which prevents all non-resolved proposals from settling while leaving resolved disputes unaffected. OOv2 contract types only — setting it for another `ORACLE_TYPE` throws at startup. Example: `["0xabc...def:5"]`. ## Behavior diff --git a/packages/monitor-v2/src/bot-oo/SettleOOv2Requests.ts b/packages/monitor-v2/src/bot-oo/SettleOOv2Requests.ts index 40f5bfe148..5259825f1c 100644 --- a/packages/monitor-v2/src/bot-oo/SettleOOv2Requests.ts +++ b/packages/monitor-v2/src/bot-oo/SettleOOv2Requests.ts @@ -21,7 +21,8 @@ function chunk(arr: T[], size: number): T[][] { return chunks; } -// Applies the include list to the discovered proposals. +// Applies the standard OOv2 include list. Managed OOv2 applies it after checking request state so resolved disputes +// preserve their normal settlement behavior. function filterByIncludeList( logger: typeof Logger, params: MonitoringParams, @@ -81,11 +82,8 @@ export async function settleOOv2Requests( params: MonitoringParams, gasEstimator: GasEstimator ): Promise { - let managedIncludeList: Set | undefined; - if (params.oracleType === "ManagedOptimisticOracleV2") { - if (!params.settleIncludeList) throw new Error("Managed OOv2 settlement requires an include list"); - managedIncludeList = params.settleIncludeList; - } + if (params.oracleType === "ManagedOptimisticOracleV2" && !params.settleIncludeList) + throw new Error("Managed OOv2 settlement requires an include list"); const oo = await getContractInstanceWithProvider( "OptimisticOracleV2", @@ -93,79 +91,95 @@ export async function settleOOv2Requests( params.contractAddress ); + const searchConfig = await computeEventSearch( + params.provider, + params.blockFinder, + params.timeLookback, + params.maxBlockLookBack + ); + + logger.debug({ + at: "OOv2Bot", + message: "Querying ProposePrice events", + fromBlock: searchConfig.fromBlock, + toBlock: searchConfig.toBlock, + maxBlockLookBack: searchConfig.maxBlockLookBack, + }); + const proposalsStartedAt = Date.now(); let proposals: ProposePriceEvent[]; - let settlements: SettleEvent[]; - if (managedIncludeList) { - proposals = await getManagedIncludedProposals(oo, managedIncludeList); - settlements = []; + try { + proposals = await paginatedEventQuery(oo, oo.filters.ProposePrice(), searchConfig); logger.debug({ at: "OOv2Bot", - message: "Queried included ProposePrice events", - requested: managedIncludeList.size, + message: "Queried ProposePrice events", count: proposals.length, + elapsedMs: Date.now() - proposalsStartedAt, }); - } else { - const searchConfig = await computeEventSearch( - params.provider, - params.blockFinder, - params.timeLookback, - params.maxBlockLookBack - ); - - logger.debug({ + } catch (error) { + logger.error({ at: "OOv2Bot", - message: "Querying ProposePrice events", + message: "Failed querying ProposePrice events", fromBlock: searchConfig.fromBlock, toBlock: searchConfig.toBlock, maxBlockLookBack: searchConfig.maxBlockLookBack, + ...getSettleTxErrorLogFields(error), }); - const proposalsStartedAt = Date.now(); - try { - proposals = await paginatedEventQuery(oo, oo.filters.ProposePrice(), searchConfig); - logger.debug({ - at: "OOv2Bot", - message: "Queried ProposePrice events", - count: proposals.length, - elapsedMs: Date.now() - proposalsStartedAt, - }); - } catch (error) { - logger.error({ - at: "OOv2Bot", - message: "Failed querying ProposePrice events", - fromBlock: searchConfig.fromBlock, - toBlock: searchConfig.toBlock, - maxBlockLookBack: searchConfig.maxBlockLookBack, - ...getSettleTxErrorLogFields(error), - }); - throw error; - } + throw error; + } + logger.debug({ + at: "OOv2Bot", + message: "Querying Settle events", + fromBlock: searchConfig.fromBlock, + toBlock: searchConfig.toBlock, + maxBlockLookBack: searchConfig.maxBlockLookBack, + }); + const settlementsStartedAt = Date.now(); + let settlements: SettleEvent[]; + try { + settlements = await paginatedEventQuery(oo, oo.filters.Settle(), searchConfig); logger.debug({ at: "OOv2Bot", - message: "Querying Settle events", + message: "Queried Settle events", + count: settlements.length, + elapsedMs: Date.now() - settlementsStartedAt, + }); + } catch (error) { + logger.error({ + at: "OOv2Bot", + message: "Failed querying Settle events", fromBlock: searchConfig.fromBlock, toBlock: searchConfig.toBlock, maxBlockLookBack: searchConfig.maxBlockLookBack, + ...getSettleTxErrorLogFields(error), }); - const settlementsStartedAt = Date.now(); + throw error; + } + + if (params.oracleType === "ManagedOptimisticOracleV2" && params.settleIncludeList) { try { - settlements = await paginatedEventQuery(oo, oo.filters.Settle(), searchConfig); + const includedProposals = await getManagedIncludedProposals(oo, params.settleIncludeList); + const proposalsById = new Map( + proposals.map((proposal) => [proposalEventId(proposal.transactionHash, proposal.logIndex), proposal] as const) + ); + for (const proposal of includedProposals) { + proposalsById.set(proposalEventId(proposal.transactionHash, proposal.logIndex), proposal); + } + proposals = [...proposalsById.values()]; logger.debug({ at: "OOv2Bot", - message: "Queried Settle events", - count: settlements.length, - elapsedMs: Date.now() - settlementsStartedAt, + message: "Queried included ProposePrice events", + requested: params.settleIncludeList.size, + count: includedProposals.length, }); } catch (error) { + // Manual candidates fail closed, but their lookup must not block historical disputed settlements. logger.error({ at: "OOv2Bot", - message: "Failed querying Settle events", - fromBlock: searchConfig.fromBlock, - toBlock: searchConfig.toBlock, - maxBlockLookBack: searchConfig.maxBlockLookBack, + message: "Failed querying included ProposePrice events", + requested: params.settleIncludeList.size, ...getSettleTxErrorLogFields(error), }); - throw error; } } @@ -173,7 +187,10 @@ export async function settleOOv2Requests( const unsettledRequests = proposals.filter((e) => !settledKeys.has(requestKey(e.args))); - const requestsToSettle = filterByIncludeList(logger, params, unsettledRequests); + const requestsToSettle = + params.oracleType === "ManagedOptimisticOracleV2" + ? unsettledRequests + : filterByIncludeList(logger, params, unsettledRequests); const requestsToSettleTxCount = params.settleBatchSize > 1 ? Math.ceil(requestsToSettle.length / params.settleBatchSize) : requestsToSettle.length; @@ -229,15 +246,27 @@ export async function settleOOv2Requests( } } - // Standard OOv2 can optionally limit settlement to disputes resolved by the DVM. - if (params.oracleType === "OptimisticOracleV2" && params.botModes.settleOnlyDisputed) { + // Managed OOv2 always preserves normal disputed settlement; its include list governs non-resolved requests. + if (params.oracleType === "ManagedOptimisticOracleV2" || params.botModes.settleOnlyDisputed) { const state = await oo.getState( req.args.requester, req.args.identifier, req.args.timestamp, req.args.ancillaryData ); - if (state !== STATE_RESOLVED) { + if (params.oracleType === "ManagedOptimisticOracleV2" && state !== STATE_RESOLVED) { + const id = proposalEventId(req.transactionHash, req.logIndex); + if (!params.settleIncludeList?.has(id)) { + logger.debug({ + at: "OOv2Bot", + message: "Skipping non-resolved Managed OOv2 request outside settlement include list", + requestKey: requestKey(req.args), + proposalEventId: id, + state, + }); + return null; + } + } else if (params.oracleType !== "ManagedOptimisticOracleV2" && state !== STATE_RESOLVED) { logger.debug({ at: "OOv2Bot", message: "Skipping non-disputed request (settleOnlyDisputed)", diff --git a/packages/monitor-v2/src/bot-oo/common.ts b/packages/monitor-v2/src/bot-oo/common.ts index babcfb735c..685a602699 100644 --- a/packages/monitor-v2/src/bot-oo/common.ts +++ b/packages/monitor-v2/src/bot-oo/common.ts @@ -47,7 +47,8 @@ export function parseProposalIdList(value: string | undefined, envName: string): return new Set(ids); } -// Parses SETTLE_INCLUDE_LIST for OOv2 contracts. Managed OOv2 uses it as the complete settlement candidate set. +// Parses SETTLE_INCLUDE_LIST for OOv2 contracts. For Managed OOv2 it governs non-resolved requests only; normal +// resolved-dispute settlement is always preserved. export function parseSettleIncludeList(env: NodeJS.ProcessEnv, oracleType: OracleType): Set | undefined { // Fail fast on the removed setting so stale deployments cannot silently fall back to settling every proposal. if (env.SETTLE_EXCLUDE_LIST !== undefined) @@ -57,7 +58,8 @@ export function parseSettleIncludeList(env: NodeJS.ProcessEnv, oracleType: Oracl if (configuredIncludeList && oracleType !== "OptimisticOracleV2" && oracleType !== "ManagedOptimisticOracleV2") throw new Error("SETTLE_INCLUDE_LIST is only supported for OptimisticOracleV2 and ManagedOptimisticOracleV2"); - // Default Managed OOv2 settlement to an empty include list so missing configuration cannot settle any proposals. + // Default Managed OOv2 settlement to an empty include list so missing configuration cannot settle non-resolved + // proposals. return oracleType === "ManagedOptimisticOracleV2" && configuredIncludeList === undefined ? new Set() : configuredIncludeList; @@ -76,8 +78,8 @@ 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 - // Proposal event ids (":"). Standard OOv2 filters its discovered proposals with this list. - // Managed OOv2 fetches and processes only proposals in this list. + // Proposal event ids (":"). Standard OOv2 applies the include filter to all proposals. Managed + // OOv2 applies it only to non-resolved proposals; resolved disputes follow normal settlement. settleIncludeList?: Set; } diff --git a/packages/monitor-v2/test/OptimisticOracleV2Bot.ts b/packages/monitor-v2/test/OptimisticOracleV2Bot.ts index ea989a4598..f1dd8cd216 100644 --- a/packages/monitor-v2/test/OptimisticOracleV2Bot.ts +++ b/packages/monitor-v2/test/OptimisticOracleV2Bot.ts @@ -491,67 +491,63 @@ describe("OptimisticOracleV2Bot", function () { assert.isAbove(settledIndex, -1, "Disputed request should be settled when settleOnlyDisputed is true"); }); - it("Managed OOv2 only settles disputed requests 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(), + for (const listMode of ["empty include", "invalid include"] as const) { + it(`Managed OOv2 settles disputed requests when using the ${listMode} list`, async function () { + await ( + await optimisticOracleV2.requestPrice( defaultOptimisticOracleV2Identifier, 0, ancillaryData, - ethers.utils.parseEther("1") + bondToken.address, + 0 ) - ).wait(); - - await ( - await optimisticOracleV2 - .connect(disputer) - .disputePrice(await requester.getAddress(), defaultOptimisticOracleV2Identifier, 0, ancillaryData) - ).wait(); - - const pending = await mockOracle.getPendingQueries(); - const last = getLast(pending, "Expected a pending DVM query"); - await ( - await mockOracle.pushPrice(last.identifier, last.time, last.ancillaryData, ethers.utils.parseEther("1")) - ).wait(); - - const { spy, logger } = makeSpyLogger(); - const params = await createParams("ManagedOptimisticOracleV2", optimisticOracleV2.address); - params.settleIncludeList = new Set(); - await gasEstimator.update(); - await settleRequests(logger, params, gasEstimator); - - let settlementLogs = spy.getCalls().filter((call) => call.lastArg?.message === "Price Request Settled ✅"); - assert.equal(settlementLogs.length, 0, "A disputed request outside the include list must not be settled"); + ).wait(); - spy.resetHistory(); - params.settleIncludeList = new Set([getProposalEventId(proposeReceipt)]); - await settleRequests(logger, params, gasEstimator); + await ( + await optimisticOracleV2 + .connect(proposer) + .proposePrice( + await requester.getAddress(), + defaultOptimisticOracleV2Identifier, + 0, + ancillaryData, + ethers.utils.parseEther("1") + ) + ).wait(); - settlementLogs = spy.getCalls().filter((call) => call.lastArg?.message === "Price Request Settled ✅"); - assert.equal(settlementLogs.length, 1, "An included disputed request should be settled"); - }); + await ( + await optimisticOracleV2 + .connect(disputer) + .disputePrice(await requester.getAddress(), defaultOptimisticOracleV2Identifier, 0, ancillaryData) + ).wait(); - it("Fails closed when a Managed OOv2 include cannot be loaded", async function () { - const { logger } = makeSpyLogger(); - const params = await createParams("ManagedOptimisticOracleV2", optimisticOracleV2.address); - params.settleIncludeList = new Set([proposalEventId(`0x${"0".repeat(64)}`, 0)]); + const pending = await mockOracle.getPendingQueries(); + const last = getLast(pending, "Expected a pending DVM query"); + await ( + await mockOracle.pushPrice(last.identifier, last.time, last.ancillaryData, ethers.utils.parseEther("1")) + ).wait(); - let error: unknown; - try { + const { spy, logger } = makeSpyLogger(); + const params = await createParams("ManagedOptimisticOracleV2", optimisticOracleV2.address); + if (listMode === "empty include") { + params.settleIncludeList = new Set(); + } else { + params.settleIncludeList = new Set([proposalEventId(`0x${"0".repeat(64)}`, 0)]); + } + await gasEstimator.update(); await settleRequests(logger, params, gasEstimator); - } catch (err) { - error = err; - } - assert.instanceOf(error, Error); - assert.match((error as Error).message, /Could not find included ProposePrice event/); - }); + const settlementLogs = spy.getCalls().filter((call) => call.lastArg?.message === "Price Request Settled ✅"); + assert.equal(settlementLogs.length, 1, "Include lists must not block normal disputed settlement"); + + if (listMode === "invalid include") { + assert.isTrue( + spy.getCalls().some((call) => call.lastArg?.message === "Failed querying included ProposePrice events"), + "Invalid direct includes should be logged without blocking disputed settlement" + ); + } + }); + } it("Uses the include list for non-disputed Managed OOv2 proposals", async function () { await ( @@ -582,6 +578,17 @@ describe("OptimisticOracleV2Bot", function () { 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 skipLog = getLast( + spy + .getCalls() + .filter( + (call) => + call.lastArg?.message === "Skipping non-resolved Managed OOv2 request outside settlement include list" + ), + "Expected include list skip log" + ).lastArg; + assert.equal(skipLog.proposalEventId, getProposalEventId(proposeReceipt)); } // An include list containing the proposal: it settles. @@ -589,7 +596,8 @@ describe("OptimisticOracleV2Bot", function () { const { spy, logger } = makeSpyLogger(); const params = await createParams("ManagedOptimisticOracleV2", optimisticOracleV2.address); params.settleIncludeList = new Set([getProposalEventId(proposeReceipt)]); - // The direct include-list lookup does not depend on the historical event range. + params.botModes.settleOnlyDisputed = true; + // Exclude the proposal from the historical event range to exercise the direct include-list lookup. params.timeLookback = 0; params.blockFinder = new BlockFinder(params.provider.getBlock.bind(params.provider), undefined, params.chainId); await gasEstimator.update();