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`. `OptimisticOracleV2` only. Example: `["0xabc...def:5"]`.
- `SETTLE_EXCLUDE_LIST`: JSON array of `"<txHash>:<logIndex>"` proposal identifiers to **skip**. Ignored when `SETTLE_INCLUDE_LIST` is set. `OptimisticOracleV2` only.

## 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
38 changes: 38 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,45 @@ export { Logger } from "@uma/financial-templates-lib";
export { computeEventSearch } from "../bot-utils/events";
export { getContractInstanceWithProvider } from "../utils/contracts";
import { BaseMonitoringParams, startupLogLevel as baseStartup, initBaseMonitoringParams } from "../bot-utils/base";
import { proposalEventId } from "./requestKey";

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

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

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

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

// Parses a JSON array of "<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.
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) || parsed.length === 0) {
throw new Error(`${envName} must be a non-empty JSON array of "<txHash>:<logIndex>" strings`);
Comment on lines +31 to +32
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 Accept empty settlement lists

When SETTLE_INCLUDE_LIST or SETTLE_EXCLUDE_LIST is explicitly configured as [], the bot now throws during startup even though the README documents these vars as JSON arrays and empty arrays are a common no-op/default value for list env vars. This is especially surprising for SETTLE_EXCLUDE_LIST=[], which should behave the same as unset, and for SETTLE_INCLUDE_LIST=[], which can naturally mean settle no listed proposals; rejecting it can break deployments that template optional lists as empty arrays.

Useful? React with 👍 / 👎.

}

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);
}

export interface BotModes {
settleRequestsEnabled: boolean;
settleOnlyDisputed: boolean; // Supported for OptimisticOracleV2 (incl. ManagedOOv2); ignored for OOv1 and SkinnyOO.
Expand All @@ -27,6 +55,11 @@ export interface MonitoringParams extends BaseMonitoringParams {
executionDeadline?: number; // Timestamp in sec for when to stop settling, defaults to 4 minutes from now in serverless
settleBatchSize: number; // Number of settle calls to batch via multicall (requires MultiCaller on contract), defaults to 1
settleMinProposalAgeSeconds: number; // Minimum proposal age before settlement, defaults to 2h15m
// Include/exclude lists of proposal event ids ("<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 +98,9 @@ export const initMonitoringParams = async (env: NodeJS.ProcessEnv): Promise<Moni
DEFAULT_SETTLE_MIN_PROPOSAL_AGE_SECONDS
);

const settleIncludeList = parseProposalIdList(env.SETTLE_INCLUDE_LIST, "SETTLE_INCLUDE_LIST");
const settleExcludeList = parseProposalIdList(env.SETTLE_EXCLUDE_LIST, "SETTLE_EXCLUDE_LIST");

return {
...base,
botModes,
Expand All @@ -74,6 +110,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}`;
80 changes: 80 additions & 0 deletions packages/monitor-v2/test/OptimisticOracleV2Bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { spyLogIncludes, spyLogLevel, GasEstimator } from "@uma/financial-templates-lib";
import { assert } from "chai";
import { OracleType } from "../src/bot-oo/common";
import { proposalEventId } from "../src/bot-oo/requestKey";
import { settleRequests } from "../src/bot-oo/SettleRequests";
import { defaultLiveness, defaultOptimisticOracleV2Identifier } from "./constants";
import { optimisticOracleV2Fixture } from "./fixtures/OptimisticOracleV2.Fixture";
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,77 @@ describe("OptimisticOracleV2Bot", function () {
.findIndex((c) => c.lastArg?.message === "Price Request Settled ✅" && c.lastArg?.at === "OOv2Bot");
assert.isAbove(settledIndex, -1, "Disputed request should be settled when settleOnlyDisputed is true");
});

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

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

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

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

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

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

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

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

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

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

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

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