Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

test: Second opinion oracle test #857

Merged
merged 22 commits into from
Oct 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
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
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) {
Copy link
Member Author

Choose a reason for hiding this comment

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

I'd move this to the protocol discovery since this may be used somewhere else, and here, take data from the context or move it under the protocol context flag to explicitly set tests state for Sepolia network.

// 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",
);
});
});
Loading