Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/monitor-v2/src/bot-oo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ node ./packages/monitor-v2/dist/bot-oo/index.js
- `SETTLE_MIN_PROPOSAL_AGE_SECONDS`: Minimum proposal age in seconds before settling OOv2 requests (default `8100`, set `0` to disable).
- `SETTLE_TIMEOUT`: Timeout in seconds for submitting settlement transactions in serverless mode (default `240`).
- `SETTLE_ONLY_DISPUTED`: When `true`, only settle requests that have been disputed (`false` by default). Supported for `OptimisticOracleV2` (including `ManagedOptimisticOracleV2`); ignored for `OptimisticOracle` and `SkinnyOptimisticOracle`.
- `SETTLE_INCLUDE_LIST`: JSON array of `"<txHash>:<logIndex>"` proposal identifiers (the transaction hash and log index of the `ProposePrice` event). When set, the bot settles **only** these proposals. Takes precedence over `SETTLE_EXCLUDE_LIST`, so an explicit empty array (`[]`) settles **nothing**; leave it unset for default behavior. `OptimisticOracleV2` only — setting it for another `ORACLE_TYPE` throws at startup. Example: `["0xabc...def:5"]`.
- `SETTLE_EXCLUDE_LIST`: JSON array of `"<txHash>:<logIndex>"` proposal identifiers to **skip**. An empty array (`[]`) behaves the same as unset. Ignored when `SETTLE_INCLUDE_LIST` is set. `OptimisticOracleV2` only — setting it for another `ORACLE_TYPE` throws at startup.

## Behavior

Expand Down
39 changes: 37 additions & 2 deletions packages/monitor-v2/src/bot-oo/SettleOOv2Requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { ethers } from "ethers";
import { computeEventSearch } from "../bot-utils/events";
import { logSettleRequest } from "./BotLogger";
import { getContractInstanceWithProvider, Logger, MonitoringParams, OptimisticOracleV2Ethers } from "./common";
import { requestKey } from "./requestKey";
import { proposalEventId, requestKey } from "./requestKey";
import type { GasEstimator } from "@uma/financial-templates-lib";
import { getSettleTxErrorLogFields, getSettleTxErrorLogLevel } from "../bot-utils/errors";

Expand All @@ -21,6 +21,39 @@ function chunk<T>(arr: T[], size: number): T[][] {
return chunks;
}

// Applies the include/exclude proposal lists. The include list is exclusive and takes precedence: when set, only its
// proposals are settled. Otherwise proposals in the exclude list are skipped. Proposals are matched by the
// transaction hash and log index of their ProposePrice event.
function filterByIncludeExclude(
logger: typeof Logger,
params: MonitoringParams,
requests: ProposePriceEvent[]
): ProposePriceEvent[] {
const { settleIncludeList, settleExcludeList } = params;
if (!settleIncludeList && !settleExcludeList) return requests;

const kept: ProposePriceEvent[] = [];
const skipped: string[] = [];
for (const req of requests) {
const id = proposalEventId(req.transactionHash, req.logIndex);
const allowed = settleIncludeList ? settleIncludeList.has(id) : !settleExcludeList?.has(id);
if (allowed) kept.push(req);
else skipped.push(id);
}

logger.debug({
at: "OOv2Bot",
message: "Applied include/exclude proposal filter",
mode: settleIncludeList ? "include" : "exclude",
listSize: (settleIncludeList ?? settleExcludeList)?.size,
kept: kept.length,
skipped: skipped.length,
skippedIds: skipped,
});

return kept;
}

export async function settleOOv2Requests(
logger: typeof Logger,
params: MonitoringParams,
Expand Down Expand Up @@ -99,7 +132,9 @@ export async function settleOOv2Requests(

const settledKeys = new Set(settlements.map((e) => requestKey(e.args)));

const requestsToSettle = proposals.filter((e) => !settledKeys.has(requestKey(e.args)));
const unsettledRequests = proposals.filter((e) => !settledKeys.has(requestKey(e.args)));

const requestsToSettle = filterByIncludeExclude(logger, params, unsettledRequests);

const requestsToSettleTxCount =
params.settleBatchSize > 1 ? Math.ceil(requestsToSettle.length / params.settleBatchSize) : requestsToSettle.length;
Expand Down
53 changes: 53 additions & 0 deletions packages/monitor-v2/src/bot-oo/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,61 @@ export { Logger } from "@uma/financial-templates-lib";
export { computeEventSearch } from "../bot-utils/events";
export { getContractInstanceWithProvider } from "../utils/contracts";
import { BaseMonitoringParams, startupLogLevel as baseStartup, initBaseMonitoringParams } from "../bot-utils/base";
import { proposalEventId } from "./requestKey";

export type OracleType = "OptimisticOracle" | "SkinnyOptimisticOracle" | "OptimisticOracleV2";

const DEFAULT_SETTLE_MIN_PROPOSAL_AGE_SECONDS = 2 * 60 * 60 + 15 * 60;

const PROPOSAL_ID_REGEX = /^(0x[0-9a-fA-F]{64}):(\d+)$/;

function getNonNegativeNumber(value: string | undefined, defaultValue: number): number {
if (value === undefined) return defaultValue;
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.max(0, parsed) : defaultValue;
}

// Parses a JSON array of "<txHash>:<logIndex>" strings into a normalized set of proposal event ids.
// Returns undefined when the env var is unset/empty so the bot keeps its default (settle everything) behavior.
// An explicit empty array is accepted: an empty exclude list behaves like unset, while an empty include list
// means settle nothing (the include list is exclusive).
export function parseProposalIdList(value: string | undefined, envName: string): Set<string> | undefined {
if (value === undefined || value.trim() === "") return undefined;

let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
throw new Error(`${envName} must be a JSON array of "<txHash>:<logIndex>" strings`);
}
if (!Array.isArray(parsed)) {
throw new Error(`${envName} must be a JSON array of "<txHash>:<logIndex>" strings`);
}

const ids = parsed.map((entry) => {
if (typeof entry !== "string") throw new Error(`${envName} entries must be "<txHash>:<logIndex>" strings`);
const match = entry.match(PROPOSAL_ID_REGEX);
if (!match) throw new Error(`Invalid ${envName} entry "${entry}"; expected "<txHash>:<logIndex>"`);
return proposalEventId(match[1], Number(match[2]));
});

return new Set(ids);
}

// Parses SETTLE_INCLUDE_LIST/SETTLE_EXCLUDE_LIST and throws when either is set for a non-OOv2 oracle type:
// only the OOv2 settler applies them, so silently ignoring a list would settle proposals the operator
// intended to skip.
export function parseSettleProposalIdLists(
env: NodeJS.ProcessEnv,
oracleType: OracleType
): { settleIncludeList?: Set<string>; settleExcludeList?: Set<string> } {
const settleIncludeList = parseProposalIdList(env.SETTLE_INCLUDE_LIST, "SETTLE_INCLUDE_LIST");
const settleExcludeList = parseProposalIdList(env.SETTLE_EXCLUDE_LIST, "SETTLE_EXCLUDE_LIST");
if ((settleIncludeList || settleExcludeList) && oracleType !== "OptimisticOracleV2")
throw new Error("SETTLE_INCLUDE_LIST/SETTLE_EXCLUDE_LIST are only supported for OptimisticOracleV2");
Comment on lines +56 to +57
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Treat empty exclude lists as unset for non-OOv2

When ORACLE_TYPE is OptimisticOracle or SkinnyOptimisticOracle and a shared deployment template renders SETTLE_EXCLUDE_LIST=[], parseProposalIdList returns an empty Set, making this condition truthy and causing startup to throw. That contradicts the documented behavior that an empty exclude list behaves like unset, so non-OOv2 bots can be prevented from starting even though the list would skip nothing.

Useful? React with 👍 / 👎.

return { settleIncludeList, settleExcludeList };
}

export interface BotModes {
settleRequestsEnabled: boolean;
settleOnlyDisputed: boolean; // Supported for OptimisticOracleV2 (incl. ManagedOOv2); ignored for OOv1 and SkinnyOO.
Expand All @@ -27,6 +71,11 @@ export interface MonitoringParams extends BaseMonitoringParams {
executionDeadline?: number; // Timestamp in sec for when to stop settling, defaults to 4 minutes from now in serverless
settleBatchSize: number; // Number of settle calls to batch via multicall (requires MultiCaller on contract), defaults to 1
settleMinProposalAgeSeconds: number; // Minimum proposal age before settlement, defaults to 2h15m
// Include/exclude lists of proposal event ids ("<txHash>:<logIndex>"). OptimisticOracleV2 only.
// When settleIncludeList is set, only those proposals are settled (it takes precedence over the exclude list).
// Otherwise, proposals in settleExcludeList are skipped. Both undefined means settle everything (default).
settleIncludeList?: Set<string>;
settleExcludeList?: Set<string>;
}

export const initMonitoringParams = async (env: NodeJS.ProcessEnv): Promise<MonitoringParams> => {
Expand Down Expand Up @@ -65,6 +114,8 @@ export const initMonitoringParams = async (env: NodeJS.ProcessEnv): Promise<Moni
DEFAULT_SETTLE_MIN_PROPOSAL_AGE_SECONDS
);

const { settleIncludeList, settleExcludeList } = parseSettleProposalIdLists(env, oracleType);

return {
...base,
botModes,
Expand All @@ -74,6 +125,8 @@ export const initMonitoringParams = async (env: NodeJS.ProcessEnv): Promise<Moni
executionDeadline,
settleBatchSize,
settleMinProposalAgeSeconds,
settleIncludeList,
settleExcludeList,
};
};

Expand Down
5 changes: 5 additions & 0 deletions packages/monitor-v2/src/bot-oo/requestKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,8 @@ export const requestKey = (args: RequestKeyArgs): string =>
[args.requester, args.identifier, args.timestamp, args.ancillaryData]
)
);

// Identifies a proposal by the transaction hash and log index of its ProposePrice event. This is the
// identifier used by the include/exclude settlement lists (matches how proposals are referenced in the explorer).
export const proposalEventId = (transactionHash: string, logIndex: number): string =>
`${transactionHash.toLowerCase()}:${logIndex}`;
105 changes: 104 additions & 1 deletion packages/monitor-v2/test/OptimisticOracleV2Bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
} from "@uma/contracts-node";
import { spyLogIncludes, spyLogLevel, GasEstimator } from "@uma/financial-templates-lib";
import { assert } from "chai";
import { OracleType } from "../src/bot-oo/common";
import { OracleType, parseProposalIdList, parseSettleProposalIdLists } from "../src/bot-oo/common";
import { proposalEventId } from "../src/bot-oo/requestKey";
import { settleRequests } from "../src/bot-oo/SettleRequests";
import { defaultLiveness, defaultOptimisticOracleV2Identifier } from "./constants";
import { optimisticOracleV2Fixture } from "./fixtures/OptimisticOracleV2.Fixture";
Expand Down Expand Up @@ -36,6 +37,12 @@ const getLast = <T>(items: T[], message: string) => {
return item;
};

const getProposalEventId = (receipt: { events?: { event?: string; transactionHash: string; logIndex: number }[] }) => {
const event = receipt.events?.find((e) => e.event === "ProposePrice");
if (event === undefined) throw new Error("Expected a ProposePrice event in the receipt");
return proposalEventId(event.transactionHash, event.logIndex);
};

describe("OptimisticOracleV2Bot", function () {
let bondToken: ExpandedERC20Ethers;
let optimisticOracleV2: OptimisticOracleV2Ethers;
Expand Down Expand Up @@ -473,4 +480,100 @@ describe("OptimisticOracleV2Bot", function () {
.findIndex((c) => c.lastArg?.message === "Price Request Settled ✅" && c.lastArg?.at === "OOv2Bot");
assert.isAbove(settledIndex, -1, "Disputed request should be settled when settleOnlyDisputed is true");
});

it("Skips proposals in the exclude list", async function () {
await (
await optimisticOracleV2.requestPrice(defaultOptimisticOracleV2Identifier, 0, ancillaryData, bondToken.address, 0)
).wait();

const proposeReceipt = await (
await optimisticOracleV2
.connect(proposer)
.proposePrice(
await requester.getAddress(),
defaultOptimisticOracleV2Identifier,
0,
ancillaryData,
ethers.utils.parseEther("1")
)
).wait();

await advanceTimerPastLiveness(timer, getReceiptBlockNumber(proposeReceipt), defaultLiveness);

const { spy, logger } = makeSpyLogger();
const params = await createParams("OptimisticOracleV2", optimisticOracleV2.address);
params.settleExcludeList = new Set([getProposalEventId(proposeReceipt)]);
await gasEstimator.update();
await settleRequests(logger, params, gasEstimator);

const settlementLogs = spy.getCalls().filter((call) => call.lastArg?.message === "Price Request Settled ✅");
assert.equal(settlementLogs.length, 0, "Excluded proposal should not be settled");
});

it("Settles only proposals in the include list", async function () {
await (
await optimisticOracleV2.requestPrice(defaultOptimisticOracleV2Identifier, 0, ancillaryData, bondToken.address, 0)
).wait();

const proposeReceipt = await (
await optimisticOracleV2
.connect(proposer)
.proposePrice(
await requester.getAddress(),
defaultOptimisticOracleV2Identifier,
0,
ancillaryData,
ethers.utils.parseEther("1")
)
).wait();

await advanceTimerPastLiveness(timer, getReceiptBlockNumber(proposeReceipt), defaultLiveness);

// An include list that does not contain the proposal: nothing settles.
{
const { spy, logger } = makeSpyLogger();
const params = await createParams("OptimisticOracleV2", optimisticOracleV2.address);
params.settleIncludeList = new Set([proposalEventId(`0x${"0".repeat(64)}`, 0)]);
await gasEstimator.update();
await settleRequests(logger, params, gasEstimator);

const settlementLogs = spy.getCalls().filter((call) => call.lastArg?.message === "Price Request Settled ✅");
assert.equal(settlementLogs.length, 0, "Proposal absent from the include list should not be settled");
}

// An include list containing the proposal: it settles.
{
const { spy, logger } = makeSpyLogger();
const params = await createParams("OptimisticOracleV2", optimisticOracleV2.address);
params.settleIncludeList = new Set([getProposalEventId(proposeReceipt)]);
await gasEstimator.update();
await settleRequests(logger, params, gasEstimator);

const settlementLogs = spy.getCalls().filter((call) => call.lastArg?.message === "Price Request Settled ✅");
assert.equal(settlementLogs.length, 1, "Proposal present in the include list should be settled");
}
});

it("Accepts explicit empty include/exclude lists", async function () {
// Templated deployments commonly render optional list env vars as "[]"; this must not throw.
const includeList = parseProposalIdList("[]", "SETTLE_INCLUDE_LIST");
assert.instanceOf(includeList, Set);
assert.equal(includeList?.size, 0);

const excludeList = parseProposalIdList("[]", "SETTLE_EXCLUDE_LIST");
assert.instanceOf(excludeList, Set);
assert.equal(excludeList?.size, 0);
});

it("Rejects include/exclude lists for non-OOv2 oracle types", async function () {
// Only the OOv2 settler applies these lists; silently ignoring them would settle proposals the operator
// intended to skip, so startup must fail instead.
const env = { SETTLE_EXCLUDE_LIST: JSON.stringify([`0x${"0".repeat(64)}:0`]) } as NodeJS.ProcessEnv;
assert.throws(() => parseSettleProposalIdLists(env, "OptimisticOracle"), /only supported for OptimisticOracleV2/);
assert.throws(
() => parseSettleProposalIdLists(env, "SkinnyOptimisticOracle"),
/only supported for OptimisticOracleV2/
);
assert.doesNotThrow(() => parseSettleProposalIdLists(env, "OptimisticOracleV2"));
});
});