Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
feat(monitors): batch ManagedOOv2 settlements via multicall
Add SETTLE_BATCH_SIZE env var to batch multiple settle calls into a
single multicall transaction for ManagedOptimisticOracleV2 contracts.
When set to 1 (default), existing one-by-one behavior is preserved.
On batch failure, falls back to one-by-one for that batch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
  • Loading branch information
mrice32 and claude committed Feb 24, 2026
commit 3f95062061a7e9820e1d34c40047d9a51ffd5dcd
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity ^0.8.0;

import "../implementation/OptimisticOracleV2.sol";
import "../../common/implementation/MultiCaller.sol";

// Test contract that combines OptimisticOracleV2 with MultiCaller, mirroring ManagedOptimisticOracleV2.
contract OptimisticOracleV2Multicaller is OptimisticOracleV2, MultiCaller {
constructor(
uint256 _liveness,
address _finderAddress,
address _timerAddress
) OptimisticOracleV2(_liveness, _finderAddress, _timerAddress) {}
}
119 changes: 119 additions & 0 deletions packages/monitor-v2/src/bot-oo/SettleOOv2Requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ import { requestKey } from "./requestKey";
import type { GasEstimator } from "@uma/financial-templates-lib";
import { getSettleTxErrorLogLevel } from "../bot-utils/errors";

const MULTICALL_ABI = ["function multicall(bytes[] calldata data) external returns (bytes[] memory results)"];

function chunk<T>(arr: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < arr.length; i += size) {
chunks.push(arr.slice(i, i + size));
}
return chunks;
}

export async function settleOOv2Requests(
logger: typeof Logger,
params: MonitoringParams,
Expand Down Expand Up @@ -71,6 +81,20 @@ export async function settleOOv2Requests(
});
}

if (params.settleBatchSize > 1) {
await settleInBatches(logger, params, oo, settleableRequests, gasEstimator);
} else {
await settleOneByOne(logger, params, oo, settleableRequests, gasEstimator);
}
}

async function settleOneByOne(
logger: typeof Logger,
params: MonitoringParams,
oo: OptimisticOracleV2Ethers,
settleableRequests: ProposePriceEvent[],
gasEstimator: GasEstimator
): Promise<void> {
const ooWithSigner = oo.connect(params.signer);

for (const [i, req] of settleableRequests.entries()) {
Expand Down Expand Up @@ -119,3 +143,98 @@ export async function settleOOv2Requests(
}
}
}

async function settleBatch(
logger: typeof Logger,
params: MonitoringParams,
oo: OptimisticOracleV2Ethers,
batch: ProposePriceEvent[],
gasEstimator: GasEstimator
): Promise<void> {
const encodedCalls = batch.map((req) =>
oo.interface.encodeFunctionData("settle", [
req.args.requester,
req.args.identifier,
req.args.timestamp,
req.args.ancillaryData,
])
);

const multicaller = new ethers.Contract(oo.address, MULTICALL_ABI, params.signer);
const gasPricing = gasEstimator.getCurrentFastPriceEthers();

const estimatedGas = await multicaller.estimateGas.multicall(encodedCalls);
const gasLimit = estimatedGas.mul(params.gasLimitMultiplier).div(100);

const tx = await multicaller.multicall(encodedCalls, { ...gasPricing, gasLimit });
const receipt = await tx.wait();

// Parse Settle events from receipt logs using the OOv2 interface.
for (const log of receipt.logs) {
try {
const parsed = oo.interface.parseLog(log);
if (parsed.name === "Settle") {
const matchingReq = batch.find(
(req) =>
req.args.requester === parsed.args.requester &&
req.args.identifier === parsed.args.identifier &&
req.args.timestamp.eq(parsed.args.timestamp) &&
req.args.ancillaryData === parsed.args.ancillaryData
);
if (matchingReq) {
await logSettleRequest(
logger,
{
tx: tx.hash,
requester: matchingReq.args.requester,
identifier: matchingReq.args.identifier,
timestamp: matchingReq.args.timestamp,
ancillaryData: matchingReq.args.ancillaryData,
price: parsed.args.price ?? ethers.constants.Zero,
},
params
);
}
}
} catch {
// Log entry not from OOv2 interface, skip.
}
}
}

async function settleInBatches(
logger: typeof Logger,
params: MonitoringParams,
oo: OptimisticOracleV2Ethers,
settleableRequests: ProposePriceEvent[],
gasEstimator: GasEstimator
): Promise<void> {
const batches = chunk(settleableRequests, params.settleBatchSize);

let settled = 0;
for (const batch of batches) {
if (params.executionDeadline && Date.now() / 1000 >= params.executionDeadline) {
logger.warn({
at: "OOv2Bot",
message: "Execution deadline reached, skipping settlement",
remainingRequests: settleableRequests.length - settled,
});
break;
}

try {
await settleBatch(logger, params, oo, batch, gasEstimator);
settled += batch.length;
} catch (error) {
// Multicall reverts the entire batch if any call fails. Fall back to one-by-one for this batch.
logger.warn({
at: "OOv2Bot",
message: "Multicall batch failed, falling back to one-by-one settlement",
batchSize: batch.length,
error,
});
await settleOneByOne(logger, params, oo, batch, gasEstimator);
settled += batch.length;
}
}
}
4 changes: 4 additions & 0 deletions packages/monitor-v2/src/bot-oo/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface MonitoringParams extends BaseMonitoringParams {
contractAddress: string;
settleableCheckBlock: number; // Block number to check for settleable requests, defaults to 5 minutes ago
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
}

export const initMonitoringParams = async (env: NodeJS.ProcessEnv): Promise<MonitoringParams> => {
Expand Down Expand Up @@ -47,13 +48,16 @@ export const initMonitoringParams = async (env: NodeJS.ProcessEnv): Promise<Moni
const settleTimeout = Number(env.SETTLE_TIMEOUT) || 4 * 60; // Default to 4 minutes from now in serverless
const executionDeadline = base.pollingDelay === 0 ? currentTimestamp + settleTimeout : undefined;

const settleBatchSize = Math.max(1, Number(env.SETTLE_BATCH_SIZE) || 1);

return {
...base,
botModes,
oracleType,
contractAddress,
settleableCheckBlock,
executionDeadline,
settleBatchSize,
};
};

Expand Down
105 changes: 105 additions & 0 deletions packages/monitor-v2/test/OptimisticOracleV2Bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import { hre, Signer, toUtf8Bytes, toUtf8String } from "./utils";
import { makeMonitoringParamsOO } from "./helpers/monitoring";
import { makeSpyLogger } from "./helpers/logging";
import { advanceTimerPastLiveness } from "./helpers/time";
import { addGlobalHardhatTestingAddress } from "@uma/common";
import { defaultCurrency } from "./constants";
import { getContractFactory } from "./utils";

const ethers = hre.ethers;

Expand Down Expand Up @@ -193,4 +196,106 @@ describe("OptimisticOracleV2Bot", function () {
const settlementLogs = spy.getCalls().filter((call) => call.lastArg?.message === "Price Request Settled ✅");
assert.equal(settlementLogs.length, 0, "No settlement logs should be generated on subsequent runs");
});

it("Settles multiple requests in a single multicall batch", async function () {
// Deploy an OOv2 with MultiCaller (mimics ManagedOptimisticOracleV2).
const [deployer] = (await ethers.getSigners()) as Signer[];
const uma = await umaEcosystemFixture();

const mcBondToken = (await (await getContractFactory("ExpandedERC20", deployer)).deploy(
defaultCurrency.name,
defaultCurrency.symbol,
defaultCurrency.decimals
)) as ExpandedERC20Ethers;
await uma.collateralWhitelist.addToWhitelist(mcBondToken.address);
await uma.store.setFinalFee(mcBondToken.address, { rawValue: defaultCurrency.finalFee });
await uma.identifierWhitelist.addSupportedIdentifier(defaultOptimisticOracleV2Identifier);

// Deploy the combined OOv2+MultiCaller contract via hardhat compilation.
const oov2McFactory = await ethers.getContractFactory("OptimisticOracleV2Multicaller", deployer);
const oov2Mc = (await oov2McFactory.deploy(
defaultLiveness,
uma.finder.address,
uma.timer.address
)) as OptimisticOracleV2Ethers;
addGlobalHardhatTestingAddress("OptimisticOracleV2", oov2Mc.address);

// Mint bonds and approve.
const bond = ethers.utils.parseEther("5000");
await mcBondToken.addMinter(await deployer.getAddress());
await mcBondToken.mint(await deployer.getAddress(), bond);
await mcBondToken.approve(oov2Mc.address, bond);

// Create 3 requests with different ancillary data.
const ancillaryDataItems = [
toUtf8Bytes("Multicall question 1"),
toUtf8Bytes("Multicall question 2"),
toUtf8Bytes("Multicall question 3"),
];

let lastProposeBlock = 0;
for (const data of ancillaryDataItems) {
await (
await oov2Mc.requestPrice(defaultOptimisticOracleV2Identifier, 0, data, mcBondToken.address, 0)
).wait();
const proposeReceipt = await (
await oov2Mc.proposePrice(
await deployer.getAddress(),
defaultOptimisticOracleV2Identifier,
0,
data,
ethers.utils.parseEther("1")
)
).wait();
lastProposeBlock = proposeReceipt.blockNumber!;
}

// Move timer past liveness for all proposals.
await advanceTimerPastLiveness(uma.timer, lastProposeBlock, defaultLiveness);

const { spy, logger } = makeSpyLogger();
const params = await makeMonitoringParamsOO("OptimisticOracleV2", oov2Mc.address, {
settleRequestsEnabled: false,
});
params.settleBatchSize = 10; // Larger than 3, so all go in one batch.

await gasEstimator.update();
await settleRequests(logger, params, gasEstimator);

// Verify all 3 requests were settled and each was logged individually.
const settleLogs = spy.getCalls().filter((c) => c.lastArg?.message === "Price Request Settled ✅");
assert.equal(settleLogs.length, 3, "Expected 3 settlement logs");

// All 3 settlements should share the same tx hash (single multicall transaction).
// The tx hash is embedded in the mrkdwn field via createEtherscanLinkMarkdown.
const txHashes = settleLogs.map((c) => {
const mrkdwn: string = c.lastArg.mrkdwn;
// Extract tx hash - it appears after "settled in transaction " in the mrkdwn.
const match = mrkdwn.match(/0x[a-fA-F0-9]{64}/);
return match ? match[0] : null;
});
assert.isNotNull(txHashes[0], "Expected to find tx hash in log");
assert.equal(txHashes[0], txHashes[1], "All settlements should be in the same tx");
assert.equal(txHashes[1], txHashes[2], "All settlements should be in the same tx");

// Verify each ancillary data appears in the logs.
for (const data of ancillaryDataItems) {
const dataStr = toUtf8String(data);
const found = settleLogs.some((c) => c.lastArg.mrkdwn.includes(dataStr));
assert.isTrue(found, `Expected settlement log to include ancillary data: ${dataStr}`);
}

// Subsequent run should produce no settlement logs.
spy.resetHistory();
{
const params2 = await makeMonitoringParamsOO("OptimisticOracleV2", oov2Mc.address, {
settleRequestsEnabled: false,
});
params2.settleBatchSize = 10;
await gasEstimator.update();
await settleRequests(logger, params2, gasEstimator);
}
const subsequentLogs = spy.getCalls().filter((call) => call.lastArg?.message === "Price Request Settled ✅");
assert.equal(subsequentLogs.length, 0, "No settlement logs should be generated on subsequent runs");
});
});
1 change: 1 addition & 0 deletions packages/monitor-v2/test/helpers/monitoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export async function makeMonitoringParamsOO(
gasLimitMultiplier: 150,
oracleType,
contractAddress,
settleBatchSize: 1,
};
}

Expand Down