Skip to content

Commit

Permalink
Merge pull request #857 from lidofinance/feat/second-opinion-test
Browse files Browse the repository at this point in the history
test: Second opinion oracle test
  • Loading branch information
tamtamchik authored Oct 14, 2024
2 parents c2470f5 + 5888b1c commit f3a4a14
Show file tree
Hide file tree
Showing 8 changed files with 269 additions and 37 deletions.
69 changes: 56 additions & 13 deletions lib/protocol/helpers/accounting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
impersonate,
log,
ONE_GWEI,
streccak,
trace,
} from "lib";

Expand Down Expand Up @@ -352,19 +353,61 @@ const simulateReport = async (
"El Rewards Vault Balance": formatEther(elRewardsVaultBalance),
});

const [postTotalPooledEther, postTotalShares, withdrawals, elRewards] = await lido
.connect(accountingOracleAccount)
.handleOracleReport.staticCall(
reportTimestamp,
1n * 24n * 60n * 60n, // 1 day
beaconValidators,
clBalance,
withdrawalVaultBalance,
elRewardsVaultBalance,
0n,
[],
0n,
);
// NOTE: To enable negative rebase sanity checker, the static call below
// replaced with advanced eth_call with stateDiff.
// const [postTotalPooledEther1, postTotalShares1, withdrawals1, elRewards1] = await lido
// .connect(accountingOracleAccount)
// .handleOracleReport.staticCall(
// reportTimestamp,
// 1n * 24n * 60n * 60n, // 1 day
// beaconValidators,
// clBalance,
// withdrawalVaultBalance,
// elRewardsVaultBalance,
// 0n,
// [],
// 0n,
// );

// Step 1: Encode the function call data
const data = lido.interface.encodeFunctionData("handleOracleReport", [
reportTimestamp,
BigInt(24 * 60 * 60), // 1 day in seconds
beaconValidators,
clBalance,
withdrawalVaultBalance,
elRewardsVaultBalance,
BigInt(0),
[],
BigInt(0),
]);

// Step 2: Prepare the transaction object
const transactionObject = {
to: lido.address,
from: accountingOracleAccount.address,
data: data,
};

// Step 3: Prepare call parameters, state diff and perform eth_call
const accountingOracleAddr = await accountingOracle.getAddress();
const callParams = [transactionObject, "latest"];
const LAST_PROCESSING_REF_SLOT_POSITION = streccak("lido.BaseOracle.lastProcessingRefSlot");
const stateDiff = {
[accountingOracleAddr]: {
stateDiff: {
[LAST_PROCESSING_REF_SLOT_POSITION]: refSlot, // setting the processing refslot for the sanity checker
},
},
};

const returnData = await ethers.provider.send("eth_call", [...callParams, stateDiff]);

// Step 4: Decode the returned data
const [[postTotalPooledEther, postTotalShares, withdrawals, elRewards]] = lido.interface.decodeFunctionResult(
"handleOracleReport",
returnData,
);

log.debug("Simulation result", {
"Post Total Pooled Ether": formatEther(postTotalPooledEther),
Expand Down
2 changes: 1 addition & 1 deletion scripts/scratch/deployed-testnet-defaults.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@
"maxPositiveTokenRebase": 5000000,
"initialSlashingAmountPWei": 1000,
"inactivityPenaltiesAmountPWei": 101,
"clBalanceOraclesErrorUpperBPLimit": 74
"clBalanceOraclesErrorUpperBPLimit": 50
}
},
"oracleDaemonConfig": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export async function main() {
maxPositiveTokenRebase: 750_000, // 0.0075%
initialSlashingAmountPWei: 1000, // 1 ETH = 1000 PWei
inactivityPenaltiesAmountPWei: 101, // 0.101 ETH = 101 PWei
clBalanceOraclesErrorUpperBPLimit: 74, // 0.74%
clBalanceOraclesErrorUpperBPLimit: 50, // 0.5%
};

// Deploy OracleReportSanityChecker
Expand Down Expand Up @@ -60,7 +60,7 @@ export async function main() {
const proxyLocator = await ethers.getContractAt("OssifiableProxy", locatorAddress);
const proxyAdmin = await proxyLocator.proxy__getAdmin();

const proxyAdminSigner = await impersonate(proxyAdmin, ether("1"));
const proxyAdminSigner = await impersonate(proxyAdmin, ether("100"));

await updateLidoLocatorImplementation(
locatorAddress,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ interface ISecondOpinionOracle {
returns (bool success, uint256 clBalanceGwei, uint256 withdrawalVaultBalanceWei, uint256 numValidators, uint256 exitedValidators);
}

contract SecondOpinionOracleMock is ISecondOpinionOracle {
contract SecondOpinionOracle__Mock is ISecondOpinionOracle {

struct Report {
bool success;
Expand All @@ -26,6 +26,17 @@ contract SecondOpinionOracleMock is ISecondOpinionOracle {
reports[refSlot] = report;
}

function addPlainReport(uint256 refSlot, uint256 clBalanceGwei, uint256 withdrawalVaultBalanceWei) external {

reports[refSlot] = Report({
success: true,
clBalanceGwei: clBalanceGwei,
withdrawalVaultBalanceWei: withdrawalVaultBalanceWei,
numValidators: 0,
exitedValidators: 0
});
}

function removeReport(uint256 refSlot) external {
delete reports[refSlot];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ describe("OracleReportSanityChecker.sol:misc", () => {
maxPositiveTokenRebase: 5_000_000n, // 0.05%
initialSlashingAmountPWei: 1000n,
inactivityPenaltiesAmountPWei: 101n,
clBalanceOraclesErrorUpperBPLimit: 74n, // 0.74%
clBalanceOraclesErrorUpperBPLimit: 50n, // 0.5%
};

const correctLidoOracleReport = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,13 @@ describe("OracleReportSanityChecker.sol:negative-rebase", () => {
maxPositiveTokenRebase: 5_000_000n, // 0.05%
initialSlashingAmountPWei: 1000n, // 1 ETH = 1000 PWei
inactivityPenaltiesAmountPWei: 101n, // 0.101 ETH = 101 PWei
clBalanceOraclesErrorUpperBPLimit: 74n, // 0.74%
clBalanceOraclesErrorUpperBPLimit: 50n, // 0.5%
};

let originalState: string;

const deploySecondOpinionOracle = async () => {
const secondOpinionOracle = await ethers.deployContract("SecondOpinionOracleMock");
const secondOpinionOracle = await ethers.deployContract("SecondOpinionOracle__Mock");

const clOraclesRole = await checker.SECOND_OPINION_MANAGER_ROLE();
await checker.grantRole(clOraclesRole, deployer.address);
Expand Down
17 changes: 0 additions & 17 deletions test/integration/accounting.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,11 +207,6 @@ describe("Accounting", () => {
const { sharesRateBefore, sharesRateAfter } = shareRateFromEvent(tokenRebasedEvent[0]);
expect(sharesRateBefore).to.be.lessThanOrEqual(sharesRateAfter);

const postTotalSharesEvent = ctx.getEvents(reportTxReceipt, "PostTotalShares");
expect(postTotalSharesEvent[0].args.preTotalPooledEther).to.equal(
postTotalSharesEvent[0].args.postTotalPooledEther + amountOfETHLocked,
);

const ethBalanceAfter = await ethers.provider.getBalance(lido.address);
expect(ethBalanceBefore).to.equal(ethBalanceAfter + amountOfETHLocked);
});
Expand Down Expand Up @@ -260,12 +255,6 @@ describe("Accounting", () => {
ethDistributedEvent[0].args.postCLBalance,
"ETHDistributed: CL balance differs from expected",
);

const postTotalSharesEvent = ctx.getEvents(reportTxReceipt, "PostTotalShares");
expect(postTotalSharesEvent[0].args.preTotalPooledEther + REBASE_AMOUNT).to.equal(
postTotalSharesEvent[0].args.postTotalPooledEther + amountOfETHLocked,
"PostTotalShares: TotalPooledEther differs from expected",
);
});

it("Should account correctly with positive CL rebase close to the limits", async () => {
Expand Down Expand Up @@ -382,12 +371,6 @@ describe("Accounting", () => {
ethDistributedEvent[0].args.postCLBalance,
"ETHDistributed: CL balance has not increased",
);

const postTotalSharesEvent = ctx.getEvents(reportTxReceipt, "PostTotalShares");
expect(postTotalSharesEvent[0].args.preTotalPooledEther + rebaseAmount).to.equal(
postTotalSharesEvent[0].args.postTotalPooledEther + amountOfETHLocked,
"PostTotalShares: TotalPooledEther has not increased",
);
});

it("Should account correctly if no EL rewards", async () => {
Expand Down
195 changes: 195 additions & 0 deletions test/integration/second-opinion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { expect } from "chai";
import { ethers } from "hardhat";

import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers";

import { SecondOpinionOracle__Mock } from "typechain-types";

import { ether, impersonate, log, ONE_GWEI } from "lib";
import { getProtocolContext, ProtocolContext } from "lib/protocol";
import { finalizeWithdrawalQueue, norEnsureOperators, report, sdvtEnsureOperators } from "lib/protocol/helpers";

import { bailOnFailure, Snapshot } from "test/suite";

const AMOUNT = ether("100");
const MAX_DEPOSIT = 150n;
const CURATED_MODULE_ID = 1n;
const INITIAL_REPORTED_BALANCE = ether("32") * 3n; // 32 ETH * 3 validators

const ZERO_HASH = new Uint8Array(32).fill(0);

// Diff amount is 10% of total supply
function getDiffAmount(totalSupply: bigint): bigint {
return (totalSupply / 10n / ONE_GWEI) * ONE_GWEI;
}

describe("Second opinion", () => {
let ctx: ProtocolContext;

let ethHolder: HardhatEthersSigner;
let stEthHolder: HardhatEthersSigner;

let snapshot: string;
let originalState: string;

let secondOpinion: SecondOpinionOracle__Mock;
let totalSupply: bigint;

before(async () => {
ctx = await getProtocolContext();

[stEthHolder, ethHolder] = await ethers.getSigners();

snapshot = await Snapshot.take();

const { lido, depositSecurityModule, oracleReportSanityChecker } = ctx.contracts;

await finalizeWithdrawalQueue(ctx, stEthHolder, ethHolder);

await norEnsureOperators(ctx, 3n, 5n);
await sdvtEnsureOperators(ctx, 3n, 5n);

const { chainId } = await ethers.provider.getNetwork();
// Sepolia-specific initialization
if (chainId === 11155111n) {
// Sepolia deposit contract address https://sepolia.etherscan.io/token/0x7f02c3e3c98b133055b8b348b2ac625669ed295d
const sepoliaDepositContractAddress = "0x7f02C3E3c98b133055B8B348B2Ac625669Ed295D";
const bepoliaWhaleHolder = "0xf97e180c050e5Ab072211Ad2C213Eb5AEE4DF134";
const BEPOLIA_TO_TRANSFER = 20;

const bepoliaToken = await ethers.getContractAt("ISepoliaDepositContract", sepoliaDepositContractAddress);
const bepiloaSigner = await ethers.getImpersonatedSigner(bepoliaWhaleHolder);

const adapterAddr = await ctx.contracts.stakingRouter.DEPOSIT_CONTRACT();
await bepoliaToken.connect(bepiloaSigner).transfer(adapterAddr, BEPOLIA_TO_TRANSFER);
}
const dsmSigner = await impersonate(depositSecurityModule.address, AMOUNT);
await lido.connect(dsmSigner).deposit(MAX_DEPOSIT, CURATED_MODULE_ID, ZERO_HASH);

secondOpinion = await ethers.deployContract("SecondOpinionOracle__Mock", []);
const soAddress = await secondOpinion.getAddress();

const agentSigner = await ctx.getSigner("agent", AMOUNT);
await oracleReportSanityChecker
.connect(agentSigner)
.grantRole(await oracleReportSanityChecker.SECOND_OPINION_MANAGER_ROLE(), agentSigner.address);

let { beaconBalance } = await lido.getBeaconStat();
// Report initial balances if TVL is zero
if (beaconBalance === 0n) {
await report(ctx, {
clDiff: INITIAL_REPORTED_BALANCE,
clAppearedValidators: 3n,
excludeVaultsBalances: true,
});
beaconBalance = (await lido.getBeaconStat()).beaconBalance;
}
totalSupply = beaconBalance;

await oracleReportSanityChecker.connect(agentSigner).setSecondOpinionOracleAndCLBalanceUpperMargin(soAddress, 74n);
});

beforeEach(bailOnFailure);

beforeEach(async () => (originalState = await Snapshot.take()));

afterEach(async () => await Snapshot.restore(originalState));

after(async () => await Snapshot.restore(snapshot)); // Rollback to the initial state pre deployment

it("Should fail report without second opinion ready", async () => {
const { oracleReportSanityChecker } = ctx.contracts;

const reportedDiff = getDiffAmount(totalSupply);

await expect(report(ctx, { clDiff: -reportedDiff, excludeVaultsBalances: true })).to.be.revertedWithCustomError(
oracleReportSanityChecker,
"NegativeRebaseFailedSecondOpinionReportIsNotReady",
);
});

it("Should correctly report negative rebase with second opinion", async () => {
const { hashConsensus, accountingOracle } = ctx.contracts;

const reportedDiff = getDiffAmount(totalSupply);

// Provide a second opinion
const curFrame = await hashConsensus.getCurrentFrame();
const expectedBalance = (totalSupply - reportedDiff) / ONE_GWEI;
await secondOpinion.addPlainReport(curFrame.reportProcessingDeadlineSlot, expectedBalance, 0n);

const lastProcessingRefSlotBefore = await accountingOracle.getLastProcessingRefSlot();
await report(ctx, { clDiff: -reportedDiff, excludeVaultsBalances: true });
const lastProcessingRefSlotAfter = await accountingOracle.getLastProcessingRefSlot();
expect(lastProcessingRefSlotBefore).to.be.lessThan(
lastProcessingRefSlotAfter,
"LastProcessingRefSlot should be updated",
);
});

it("Should fail report with smaller second opinion cl balance", async () => {
const { hashConsensus, oracleReportSanityChecker } = ctx.contracts;

const reportedDiff = getDiffAmount(totalSupply);

const curFrame = await hashConsensus.getCurrentFrame();
const expectedBalance = (totalSupply - reportedDiff) / ONE_GWEI - 1n;
await secondOpinion.addPlainReport(curFrame.reportProcessingDeadlineSlot, expectedBalance, 0n);

await expect(report(ctx, { clDiff: -reportedDiff, excludeVaultsBalances: true })).to.be.revertedWithCustomError(
oracleReportSanityChecker,
"NegativeRebaseFailedCLBalanceMismatch",
);
});

it("Should tolerate report with slightly bigger second opinion cl balance", async () => {
const { hashConsensus, accountingOracle } = ctx.contracts;

const reportedDiff = getDiffAmount(totalSupply);

const curFrame = await hashConsensus.getCurrentFrame();
const expectedBalance = (totalSupply - reportedDiff) / ONE_GWEI;
// Less than 0.5% diff in balances
const correction = (expectedBalance * 4n) / 1000n;
await secondOpinion.addPlainReport(curFrame.reportProcessingDeadlineSlot, expectedBalance + correction, 0n);
log.debug("Reporting parameters", {
totalSupply,
reportedDiff,
expectedBalance,
correction,
reportedBalance: totalSupply - reportedDiff,
});

const lastProcessingRefSlotBefore = await accountingOracle.getLastProcessingRefSlot();
await report(ctx, { clDiff: -reportedDiff, excludeVaultsBalances: true });
const lastProcessingRefSlotAfter = await accountingOracle.getLastProcessingRefSlot();
expect(lastProcessingRefSlotBefore).to.be.lessThan(
lastProcessingRefSlotAfter,
"LastProcessingRefSlot should be updated",
);
});

it("Should fail report with significantly bigger second opinion cl balance", async () => {
const { hashConsensus, oracleReportSanityChecker } = ctx.contracts;

const reportedDiff = getDiffAmount(totalSupply);

const curFrame = await hashConsensus.getCurrentFrame();
const expectedBalance = (totalSupply - reportedDiff) / ONE_GWEI;
// More than 0.5% diff in balances
const correction = (expectedBalance * 9n) / 1000n;
await secondOpinion.addPlainReport(curFrame.reportProcessingDeadlineSlot, expectedBalance + correction, 0n);
log.debug("Reporting parameters", {
totalSupply,
reportedDiff,
expectedBalance,
correction,
"expected + correction": expectedBalance + correction,
});

await expect(report(ctx, { clDiff: -reportedDiff, excludeVaultsBalances: true })).to.be.revertedWithCustomError(
oracleReportSanityChecker,
"NegativeRebaseFailedCLBalanceMismatch",
);
});
});

0 comments on commit f3a4a14

Please sign in to comment.