diff --git a/lib/pdg.ts b/lib/pdg.ts index 63b67ca1e..ec8ecba52 100644 --- a/lib/pdg.ts +++ b/lib/pdg.ts @@ -232,3 +232,11 @@ export enum PDGPolicy { ALLOW_PROVE, ALLOW_DEPOSIT_AND_PROVE, } + +export enum ValidatorStage { + NONE, + PREDEPOSITED, + PROVEN, + ACTIVATED, + COMPENSATED, +} diff --git a/lib/protocol/helpers/vaults.ts b/lib/protocol/helpers/vaults.ts index 89abb6bcb..71d2923dc 100644 --- a/lib/protocol/helpers/vaults.ts +++ b/lib/protocol/helpers/vaults.ts @@ -21,7 +21,6 @@ import { de0x, findEventsWithInterfaces, generatePredeposit, - generateTopUp, getCurrentBlockTimestamp, impersonate, log, @@ -520,12 +519,7 @@ export const generatePredepositData = async ( }); }; -export const getProofAndDepositData = async ( - ctx: ProtocolContext, - validator: Validator, - withdrawalCredentials: string, - amount: bigint = ether("31"), -) => { +export const mockProof = async (ctx: ProtocolContext, validator: Validator) => { const { predepositGuarantee } = ctx.contracts; // Step 3: Prove and deposit the validator @@ -538,20 +532,16 @@ export const getProofAndDepositData = async ( ); const proof = await mockCLtree.buildProof(validatorIndex, beaconBlockHeader); - const postdeposit = generateTopUp(validator.container, amount); const pubkey = hexlify(validator.container.pubkey); - const witnesses = [ - { - proof, - pubkey, - validatorIndex, - childBlockTimestamp, - slot: beaconBlockHeader.slot, - proposerIndex: beaconBlockHeader.proposerIndex, - }, - ]; - return { witnesses, postdeposit }; + return { + proof, + pubkey, + validatorIndex, + childBlockTimestamp, + slot: beaconBlockHeader.slot, + proposerIndex: beaconBlockHeader.proposerIndex, + }; }; export async function calculateLockedValue( diff --git a/lib/time.ts b/lib/time.ts index 15906a7a9..73b951216 100644 --- a/lib/time.ts +++ b/lib/time.ts @@ -1,3 +1,4 @@ +import { expect } from "chai"; import { ethers } from "hardhat"; import { time } from "@nomicfoundation/hardhat-network-helpers"; @@ -48,8 +49,14 @@ export async function getNextBlock() { } export async function advanceChainTime(seconds: bigint) { - await ethers.provider.send("evm_increaseTime", [Number(seconds)]); + const currentTimestamp = await getCurrentBlockTimestamp(); + await ethers.provider.send("evm_setNextBlockTimestamp", [Number(currentTimestamp + seconds)]); await ethers.provider.send("evm_mine"); + + expect(await getCurrentBlockTimestamp()).to.be.equal( + currentTimestamp + seconds, + "Chain time was not advanced correctly", + ); } export function formatTimeInterval(sec: number | bigint) { diff --git a/test/integration/vaults/disconnected.integration.ts b/test/integration/vaults/disconnected.integration.ts index a623f481c..3a9af0f21 100644 --- a/test/integration/vaults/disconnected.integration.ts +++ b/test/integration/vaults/disconnected.integration.ts @@ -7,6 +7,7 @@ import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { Dashboard, DepositContract, StakingVault } from "typechain-types"; import { + addressToWC, certainAddress, ether, generateDepositStruct, @@ -17,12 +18,13 @@ import { MAX_SANE_SETTLED_GROWTH, toGwei, toLittleEndian64, + ValidatorStage, } from "lib"; import { createVaultWithDashboard, - getProofAndDepositData, getProtocolContext, getPubkeys, + mockProof, ProtocolContext, reportVaultDataWithProof, setupLidoForVaults, @@ -311,7 +313,7 @@ describe("Integration: Actions with vault disconnected from hub", () => { ); }); - it("Can deposit to beacon chain using predeposit guarantee", async () => { + it("Can deposit to beacon chain using PDG", async () => { const { predepositGuarantee } = ctx.contracts; const withdrawalCredentials = await stakingVault.withdrawalCredentials(); const validator = generateValidator(withdrawalCredentials, true); @@ -338,26 +340,112 @@ describe("Integration: Actions with vault disconnected from hub", () => { anyValue, ); - const { witnesses, postdeposit } = await getProofAndDepositData( - ctx, - validator, - withdrawalCredentials, - ether("2016"), - ); + const witness = await mockProof(ctx, validator); await expect( - predepositGuarantee.connect(nodeOperator).proveWCActivateAndTopUpValidators(witnesses, [postdeposit.amount]), + predepositGuarantee.connect(nodeOperator).proveWCActivateAndTopUpValidators([witness], [ether("2016")]), ) .to.emit(predepositGuarantee, "ValidatorProven") - .withArgs(witnesses[0].pubkey, nodeOperator, await stakingVault.getAddress(), withdrawalCredentials) + .withArgs(witness.pubkey, nodeOperator, await stakingVault.getAddress(), withdrawalCredentials) .to.emit(depositContract, "DepositEvent") - .withArgs( - postdeposit.pubkey, - withdrawalCredentials, - toLittleEndian64(toGwei(ether("2047"))), - anyValue, - anyValue, - ); + .withArgs(witness.pubkey, withdrawalCredentials, toLittleEndian64(toGwei(ether("2047"))), anyValue, anyValue); + }); + + it("Can deposit to beacon chain using PDG even if messing with staged balance", async () => { + const { predepositGuarantee, vaultHub } = ctx.contracts; + const withdrawalCredentials = await stakingVault.withdrawalCredentials(); + const validator = generateValidator(withdrawalCredentials, true); + + await predepositGuarantee.connect(nodeOperator).topUpNodeOperatorBalance(nodeOperator, { + value: ether("1"), + }); + + const predepositData = await generatePredeposit(validator, { + depositDomain: await predepositGuarantee.DEPOSIT_DOMAIN(), + }); + + await expect( + predepositGuarantee + .connect(nodeOperator) + .predeposit(stakingVault, [predepositData.deposit], [predepositData.depositY]), + ).to.emit(depositContract, "DepositEvent"); + + await stakingVault.connect(await impersonate(await stakingVault.depositor())).unstage(ether("1")); + + const witness = await mockProof(ctx, validator); + await expect(predepositGuarantee.connect(nodeOperator).proveWCAndActivate(witness)) + .to.emit(predepositGuarantee, "ValidatorProven") + .withArgs(witness.pubkey, nodeOperator, stakingVault, withdrawalCredentials) + .not.to.emit(depositContract, "DepositEvent") + .not.to.emit(stakingVault, "EtherUnstaged"); + + const validatorStatus = await predepositGuarantee.validatorStatus(validator.container.pubkey); + expect(validatorStatus.stage).to.equal(ValidatorStage.PROVEN); + expect(validatorStatus.stakingVault).to.equal(stakingVault); + expect(validatorStatus.nodeOperator).to.equal(nodeOperator); + + expect(await predepositGuarantee.pendingActivations(stakingVault)).to.equal(1); + + await expect(stakingVault.connect(owner).transferOwnership(vaultHub)) + .to.emit(stakingVault, "OwnershipTransferStarted") + .withArgs(owner, vaultHub); + + await expect(vaultHub.connectVault(stakingVault)).to.be.revertedWithCustomError( + vaultHub, + "InsufficientStagedBalance", + ); + + await stakingVault.connect(await impersonate(await stakingVault.depositor())).stage(ether("1")); + + await expect(vaultHub.connectVault(stakingVault)) + .to.emit(stakingVault, "OwnershipTransferred") + .withArgs(owner, vaultHub); + + expect(await vaultHub.isVaultConnected(stakingVault)).to.equal(true); + + await expect(predepositGuarantee.connect(stranger).activateValidator(validator.container.pubkey)) + .to.emit(predepositGuarantee, "ValidatorActivated") + .withArgs(validator.container.pubkey, nodeOperator, stakingVault, withdrawalCredentials); + }); + + it("Can receive compensation for disproven predeposit even if messing with staged balance", async () => { + const { predepositGuarantee } = ctx.contracts; + + await predepositGuarantee.connect(nodeOperator).topUpNodeOperatorBalance(nodeOperator, { + value: ether("1"), + }); + + const invalidWithdrawalCredentials = addressToWC(nodeOperator.address); + const invalidValidator = generateValidator(invalidWithdrawalCredentials); + + const invalidValidatorHackedWC = { + ...invalidValidator, + container: { + ...invalidValidator.container, + withdrawalCredentials: await stakingVault.withdrawalCredentials(), + }, + }; + + const predepositData = await generatePredeposit(invalidValidatorHackedWC, { + depositDomain: await predepositGuarantee.DEPOSIT_DOMAIN(), + }); + + await expect( + predepositGuarantee + .connect(nodeOperator) + .predeposit(stakingVault, [predepositData.deposit], [predepositData.depositY]), + ).to.emit(depositContract, "DepositEvent"); + + await stakingVault.connect(await impersonate(predepositGuarantee, ether("10"))).unstage(ether("1")); + + const witness = await mockProof(ctx, invalidValidator); + expect(await predepositGuarantee.pendingActivations(stakingVault)).to.equal(1); + await expect( + predepositGuarantee.connect(stranger).proveInvalidValidatorWC(witness, invalidWithdrawalCredentials), + ) + .to.emit(predepositGuarantee, "ValidatorCompensated") + .withArgs(stakingVault, nodeOperator, invalidValidator.container.pubkey, ether("0"), ether("0")) + .not.to.emit(stakingVault, "EtherUnstaged"); }); }); diff --git a/test/integration/vaults/pdg.integration.ts b/test/integration/vaults/pdg.integration.ts index 25324c06c..a105e737b 100644 --- a/test/integration/vaults/pdg.integration.ts +++ b/test/integration/vaults/pdg.integration.ts @@ -4,23 +4,14 @@ import { ethers } from "hardhat"; import { anyValue } from "@nomicfoundation/hardhat-chai-matchers/withArgs"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; -import { Dashboard, DepositContract, PinnedBeaconProxy, StakingVault } from "typechain-types"; +import { Dashboard, DepositContract, StakingVault } from "typechain-types"; -import { - addressToWC, - ether, - generatePredeposit, - generateValidator, - ONE_ETHER, - PDGPolicy, - toGwei, - toLittleEndian64, -} from "lib"; +import { ether, generateValidator, PDGPolicy, toGwei, toLittleEndian64 } from "lib"; import { createVaultWithDashboard, generatePredepositData, - getProofAndDepositData, getProtocolContext, + mockProof, ProtocolContext, reportVaultDataWithProof, setupLidoForVaults, @@ -36,11 +27,9 @@ describe("Integration: Predeposit Guarantee core functionality", () => { let stakingVault: StakingVault; let depositContract: DepositContract; let dashboard: Dashboard; - let proxy: PinnedBeaconProxy; let owner: HardhatEthersSigner; let nodeOperator: HardhatEthersSigner; let stranger: HardhatEthersSigner; - let guarantor: HardhatEthersSigner; let agent: HardhatEthersSigner; before(async () => { @@ -50,10 +39,10 @@ describe("Integration: Predeposit Guarantee core functionality", () => { await setupLidoForVaults(ctx); - [owner, nodeOperator, stranger, guarantor] = await ethers.getSigners(); + [owner, nodeOperator, stranger] = await ethers.getSigners(); // Owner can create a vault with operator as a node operator - ({ stakingVault, dashboard, proxy } = await createVaultWithDashboard( + ({ stakingVault, dashboard } = await createVaultWithDashboard( ctx, ctx.contracts.stakingVaultFactory, owner, @@ -92,158 +81,16 @@ describe("Integration: Predeposit Guarantee core functionality", () => { const withdrawalCredentials = await stakingVault.withdrawalCredentials(); const validator = generateValidator(withdrawalCredentials); - const { witnesses, postdeposit } = await getProofAndDepositData(ctx, validator, withdrawalCredentials); + const witness = await mockProof(ctx, validator); await expect( - predepositGuarantee.connect(nodeOperator).proveWCActivateAndTopUpValidators(witnesses, [postdeposit.amount]), + predepositGuarantee.connect(nodeOperator).proveWCActivateAndTopUpValidators([witness], [0]), ).to.be.revertedWithCustomError(pdg, "ResumedExpected"); await expect(pdg.connect(stranger).resume()).to.emit(pdg, "Resumed"); expect(await pdg.isPaused()).to.equal(false); }); - describe("Full cycle trustless path through PDG", () => { - async function commonSteps() { - const { predepositGuarantee } = ctx.contracts; - - // 1. The stVault's owner supplies 100 ETH to the vault - await expect(dashboard.connect(owner).fund({ value: ether("100") })) - .to.emit(stakingVault, "EtherFunded") - .withArgs(ether("100")); - - // 2. Setting stranger as a guarantor - await expect(predepositGuarantee.connect(nodeOperator).setNodeOperatorGuarantor(guarantor)) - .to.emit(predepositGuarantee, "GuarantorSet") - .withArgs(nodeOperator, await guarantor.getAddress(), nodeOperator); - - expect(await predepositGuarantee.nodeOperatorGuarantor(nodeOperator)).to.equal(guarantor); - - // 3. The Node Operator's guarantor tops up 1 ETH to the PDG contract, specifying the Node Operator's address. This serves as the predeposit guarantee collateral. - // Method called: PredepositGuarantee.topUpNodeOperatorBalance(nodeOperator) with ETH transfer. - await expect(predepositGuarantee.connect(guarantor).topUpNodeOperatorBalance(nodeOperator, { value: ether("1") })) - .to.emit(predepositGuarantee, "BalanceToppedUp") - .withArgs(nodeOperator, guarantor, ether("1")); - - // 4. The Node Operator generates validator keys and predeposit data - const withdrawalCredentials = await stakingVault.withdrawalCredentials(); - const validator = generateValidator(withdrawalCredentials); - - // Pre-requisite: fund the vault to have enough balance to start a validator - await dashboard.connect(owner).fund({ value: ether("32") }); - - const predepositData = await generatePredeposit(validator, { - depositDomain: await predepositGuarantee.DEPOSIT_DOMAIN(), - }); - - // 5. The Node Operator predeposits 1 ETH from the vault balance to the validator via the PDG contract. - // same time the PDG locks 1 ETH from the Node Operator's guarantee collateral in the PDG. - await expect( - predepositGuarantee - .connect(nodeOperator) - .predeposit(stakingVault, [predepositData.deposit], [predepositData.depositY]), - ) - .to.emit(predepositGuarantee, "BalanceLocked") - .withArgs(nodeOperator, ether("1"), ether("1")) - .to.emit(depositContract, "DepositEvent") - .withArgs( - predepositData.deposit.pubkey, - withdrawalCredentials, - toLittleEndian64(toGwei(predepositData.deposit.amount)), - predepositData.deposit.signature, - anyValue, - ); - - const { witnesses, postdeposit } = await getProofAndDepositData( - ctx, - validator, - withdrawalCredentials, - ether("99"), - ); - - // 6. Anyone (permissionless) submits a Merkle proof of the validator's appearing on the Consensus Layer to the PDG contract with the withdrawal credentials corresponding to the stVault's address. - // 6.1. Upon successful verification, 1 ETH of the Node Operator's guarantee collateral is unlocked from the PDG balance - // — making it available for withdrawal or reuse for the next validator predeposit. - await expect(predepositGuarantee.connect(stranger).proveWCActivateAndTopUpValidators(witnesses, [0])) - .to.emit(predepositGuarantee, "ValidatorProven") - .withArgs(witnesses[0].pubkey, nodeOperator, await stakingVault.getAddress(), withdrawalCredentials) - .to.emit(predepositGuarantee, "BalanceUnlocked") - .withArgs(nodeOperator, ether("1"), ether("0")) - .to.emit(depositContract, "DepositEvent") - .withArgs( - postdeposit.pubkey, - withdrawalCredentials, - toLittleEndian64(toGwei(await predepositGuarantee.ACTIVATION_DEPOSIT_AMOUNT())), - anyValue, - anyValue, - ); - - // 7. The Node Operator's guarantor withdraws the 1 ETH from the PDG contract or retains it for reuse with future validators. - const balanceBefore = await ethers.provider.getBalance(guarantor); - await expect( - predepositGuarantee.connect(guarantor).withdrawNodeOperatorBalance(nodeOperator, ether("1"), guarantor), - ) - .to.emit(predepositGuarantee, "BalanceWithdrawn") - .withArgs(nodeOperator, guarantor, ether("1")); - - const balanceAfter = await ethers.provider.getBalance(guarantor); - expect(balanceAfter).to.be.gt(balanceBefore); // Account for gas costs - - return { postdeposit }; - } - - // https://docs.lido.fi/guides/stvaults/pdg#full-cycle-trustless-path-through-pdg - it("Happy path", async () => { - const { postdeposit } = await commonSteps(); - const { predepositGuarantee } = ctx.contracts; - - // 8. The Node Operator makes a top-up deposit of the remaining 99 ETH from the vault balance to the validator through the PDG. - // Method called: PredepositGuarantee.depositToBeaconChain(stakingVault, deposits). - await expect(predepositGuarantee.connect(nodeOperator).topUpExistingValidators([postdeposit])) - .to.emit(depositContract, "DepositEvent") - .withArgs( - postdeposit.pubkey, - await stakingVault.withdrawalCredentials(), - toLittleEndian64(toGwei(postdeposit.amount)), - anyValue, // todo: check if this is correct - anyValue, - ); - }); - - it("Works with vaults deposit pauses", async () => { - const { postdeposit } = await commonSteps(); - const { predepositGuarantee } = ctx.contracts; - - // 8. The stVault's owner pauses the vault's deposits. - await expect(dashboard.connect(owner).pauseBeaconChainDeposits()).to.emit( - stakingVault, - "BeaconChainDepositsPaused", - ); - - // 9. The Node Operator tries to deposit the remaining 99 ETH from the vault balance to the validator through the PDG. - // This reverts with the "BeaconChainDepositsOnPause" error. - await expect( - predepositGuarantee.connect(nodeOperator).topUpExistingValidators([postdeposit]), - ).to.be.revertedWithCustomError(stakingVault, "BeaconChainDepositsOnPause"); - - // 10. The stVault's owner resumes the vault's deposits. - await expect(dashboard.connect(owner).resumeBeaconChainDeposits()).to.emit( - stakingVault, - "BeaconChainDepositsResumed", - ); - - // 11. The Node Operator deposits the remaining 99 ETH from the vault balance to the validator through the PDG. - await expect(predepositGuarantee.connect(nodeOperator).topUpExistingValidators([postdeposit])) - .to.emit(depositContract, "DepositEvent") - .withArgs( - postdeposit.pubkey, - await stakingVault.withdrawalCredentials(), - toLittleEndian64(toGwei(postdeposit.amount)), - anyValue, // todo: check if this is correct - anyValue, - ); - }); - }); - // https://docs.lido.fi/guides/stvaults/pdg#pdg-shortcut it("PDG shortcut", async () => { const { predepositGuarantee } = ctx.contracts; @@ -262,8 +109,6 @@ describe("Integration: Predeposit Guarantee core functionality", () => { const predepositData = await generatePredepositData(predepositGuarantee, dashboard, owner, nodeOperator, validator); - await dashboard.connect(owner).grantRole(await dashboard.FUND_ROLE(), proxy); - await reportVaultDataWithProof(ctx, stakingVault); await dashboard.connect(owner).setPDGPolicy(PDGPolicy.ALLOW_DEPOSIT_AND_PROVE); @@ -273,97 +118,32 @@ describe("Integration: Predeposit Guarantee core functionality", () => { // todo: this step fails, BUT this is the point of the test! await expect(dashboard.connect(nodeOperator).unguaranteedDepositToBeaconChain([predepositData.deposit])) .to.emit(dashboard, "UnguaranteedDeposits") - .withArgs(await stakingVault.getAddress(), 1, predepositData.deposit.amount); + .withArgs(stakingVault, 1, predepositData.deposit.amount); // check that emit the event from deposit contract - const { witnesses, postdeposit } = await getProofAndDepositData(ctx, validator, withdrawalCredentials, ether("99")); + const witness = await mockProof(ctx, validator); // 5. The stVault's owner submits a Merkle proof of the validator's appearing on the Consensus Layer to the Dashboard contract. - await expect(dashboard.connect(nodeOperator).proveUnknownValidatorsToPDG([witnesses[0]])) + await expect(dashboard.connect(nodeOperator).proveUnknownValidatorsToPDG([witness])) .to.emit(predepositGuarantee, "ValidatorProven") - .withArgs(witnesses[0].pubkey, nodeOperator, await stakingVault.getAddress(), withdrawalCredentials); + .withArgs(witness.pubkey, nodeOperator, stakingVault, withdrawalCredentials); // 6. The Oracle report confirms the validator's balance (1 ETH). The stVault's total value is then increased by 1 ETH accordingly. // (This is handled by the protocol, no actual code needed) // 7. The Node Operator deposits the remaining 99 ETH from the vault balance to the validator through the PDG. - await expect(predepositGuarantee.connect(nodeOperator).topUpExistingValidators([postdeposit])) + await expect( + predepositGuarantee + .connect(nodeOperator) + .topUpExistingValidators([{ pubkey: witness.pubkey, amount: ether("99") }]), + ) .to.emit(depositContract, "DepositEvent") .withArgs( - postdeposit.pubkey, + witness.pubkey, await stakingVault.withdrawalCredentials(), - toLittleEndian64(toGwei(postdeposit.amount)), + toLittleEndian64(toGwei(ether("99"))), anyValue, // todo: check if this is correct anyValue, ); }); - - describe("Disproven pubkey compensation", () => { - it("compensates disproven deposit", async () => { - const { predepositGuarantee } = ctx.contracts; - - // 1. The stVault's owner supplies 100 ETH to the vault - await expect(dashboard.connect(owner).fund({ value: ether("100") })) - .to.emit(stakingVault, "EtherFunded") - .withArgs(ether("100")); - - // 3. The Node Operator's guarantor tops up 1 ETH to the PDG contract, specifying the Node Operator's address. This serves as the predeposit guarantee collateral. - // Method called: PredepositGuarantee.topUpNodeOperatorBalance(nodeOperator) with ETH transfer. - await expect( - predepositGuarantee.connect(nodeOperator).topUpNodeOperatorBalance(nodeOperator, { value: ether("1") }), - ) - .to.emit(predepositGuarantee, "BalanceToppedUp") - .withArgs(nodeOperator, nodeOperator, ether("1")); - - // 4. The Node Operator generates a validator data with correct withdrawal creds - const invalidWithdrawalCredentials = addressToWC(await nodeOperator.getAddress()); - const validator = generateValidator(invalidWithdrawalCredentials); - - const invalidValidatorHackedWC = { - ...validator, - container: { ...validator.container, withdrawalCredentials: await stakingVault.withdrawalCredentials() }, - }; - - const invalidPredeposit = await generatePredeposit(invalidValidatorHackedWC, { - depositDomain: await predepositGuarantee.DEPOSIT_DOMAIN(), - }); - - // 5. The Node Operator predeposits 1 ETH from the vault balance to the validator via the PDG contract. - // same time the PDG locks 1 ETH from the Node Operator's guarantee collateral in the PDG. - await expect( - predepositGuarantee - .connect(nodeOperator) - .predeposit(stakingVault, [invalidPredeposit.deposit], [invalidPredeposit.depositY]), - ) - .to.emit(depositContract, "DepositEvent") - .withArgs( - invalidPredeposit.deposit.pubkey, - await stakingVault.withdrawalCredentials(), - toLittleEndian64(toGwei(invalidPredeposit.deposit.amount)), - invalidPredeposit.deposit.signature, - anyValue, - ) - .to.emit(predepositGuarantee, "BalanceLocked") - .withArgs(nodeOperator, ether("1"), ether("1")); - - const { witnesses } = await getProofAndDepositData(ctx, validator, invalidWithdrawalCredentials, ether("99")); - - const balance = await predepositGuarantee.nodeOperatorBalance(nodeOperator); - - // 6. Anyone (permissionless) submits a Merkle proof of the validator's appearing on the Consensus Layer to the PDG contract with the withdrawal credentials corresponding to the stVault's address. - // 6.1. Upon successful verification, 1 ETH of the Node Operator's guarantee collateral is unlocked from the PDG balance - // — making it available for withdrawal or reuse for the next validator predeposit. - await expect( - predepositGuarantee.connect(stranger).proveInvalidValidatorWC(witnesses[0], invalidWithdrawalCredentials), - ) - .to.emit(predepositGuarantee, "ValidatorCompensated") - .withArgs( - await stakingVault.getAddress(), - nodeOperator, - witnesses[0].pubkey, - balance.total - ONE_ETHER, - balance.locked - ONE_ETHER, - ); - }); - }); }); diff --git a/test/integration/vaults/quarantine.integration.ts b/test/integration/vaults/quarantine.integration.ts index dce8fe19e..0253c8870 100644 --- a/test/integration/vaults/quarantine.integration.ts +++ b/test/integration/vaults/quarantine.integration.ts @@ -244,9 +244,10 @@ describe("Integration: Quarantine", () => { // Report while quarantine is still active but near expiry await advanceChainTime(quarantinePeriod / 2n - 60n * 60n); - await reportTotalValue(INITIAL_VAULT_VALUE + depositAmount + LARGE_UNSAFE_VALUE + accruedFee, true); + await reportTotalValue(INITIAL_VAULT_VALUE + LARGE_UNSAFE_VALUE + depositAmount + accruedFee); // Wait for next refslot + await advanceChainTime(60n * 60n); await waitNextAvailableReportTime(ctx); // Withdraw after quarantine expired and refslot advanced diff --git a/test/integration/vaults/scenario/pdg-deposit.integration.ts b/test/integration/vaults/scenario/pdg-deposit.integration.ts new file mode 100644 index 000000000..8b0075a62 --- /dev/null +++ b/test/integration/vaults/scenario/pdg-deposit.integration.ts @@ -0,0 +1,296 @@ +import { expect } from "chai"; +import { BytesLike } from "ethers"; +import { ethers } from "hardhat"; + +import { anyValue } from "@nomicfoundation/hardhat-chai-matchers/withArgs"; +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { Dashboard, DepositContract, PredepositGuarantee, StakingVault } from "typechain-types"; + +import { + addressToWC, + ether, + generatePredeposit, + generateValidator, + toGwei, + toLittleEndian64, + Validator, + ValidatorStage, +} from "lib"; +import { + createVaultWithDashboard, + getProtocolContext, + mockProof, + ProtocolContext, + reportVaultDataWithProof, + setupLidoForVaults, +} from "lib/protocol"; + +import { bailOnFailure, Snapshot } from "test/suite"; + +describe("Scenario: Predeposit Guarantee happy path and frontrunning", () => { + let ctx: ProtocolContext; + let originalSnapshot: string; + + let stakingVault: StakingVault; + let depositContract: DepositContract; + let dashboard: Dashboard; + let predepositGuarantee: PredepositGuarantee; + + let owner: HardhatEthersSigner; + let nodeOperator: HardhatEthersSigner; + let guarantor: HardhatEthersSigner; + let depositor: HardhatEthersSigner; + let stranger: HardhatEthersSigner; + + before(async () => { + ctx = await getProtocolContext(); + + originalSnapshot = await Snapshot.take(); + + await setupLidoForVaults(ctx); + + [owner, nodeOperator, guarantor, depositor, stranger] = await ethers.getSigners(); + + // Owner can create a vault with operator as a node operator + ({ stakingVault, dashboard } = await createVaultWithDashboard( + ctx, + ctx.contracts.stakingVaultFactory, + owner, + nodeOperator, + )); + + depositContract = await ethers.getContractAt("DepositContract", await stakingVault.DEPOSIT_CONTRACT()); + predepositGuarantee = ctx.contracts.predepositGuarantee; + await dashboard.connect(owner).fund({ value: ether("100") }); + }); + + beforeEach(bailOnFailure); + after(async () => await Snapshot.restore(originalSnapshot)); + + async function expectPendingPredeposits(pubkeys: BytesLike[], noBalance: bigint) { + for (const pubkey of pubkeys) { + const status = await predepositGuarantee.validatorStatus(pubkey); + expect(status.stakingVault).to.equal(stakingVault); + expect(status.nodeOperator).to.equal(nodeOperator); + expect(status.stage).to.equal(ValidatorStage.PREDEPOSITED); + } + + expect(await predepositGuarantee.pendingActivations(stakingVault)).to.equal(pubkeys.length); + expect(await predepositGuarantee.nodeOperatorBalance(nodeOperator)).to.deep.equal([ + noBalance, + ether("1") * BigInt(pubkeys.length), + ]); + + expect(await stakingVault.stagedBalance()).to.equal(ether("31") * BigInt(pubkeys.length)); + expect(await predepositGuarantee.unlockedBalance(nodeOperator)).to.equal( + noBalance - ether("1") * BigInt(pubkeys.length), + ); + } + + it("Node Operator assigns a guarantor", async () => { + await expect(predepositGuarantee.connect(nodeOperator).setNodeOperatorGuarantor(guarantor)) + .to.emit(predepositGuarantee, "GuarantorSet") + .withArgs(nodeOperator, guarantor, nodeOperator); + + expect(await predepositGuarantee.nodeOperatorGuarantor(nodeOperator)).to.equal(guarantor); + }); + + it("Guarantor tops up 2 ETH to the Node Operator's balance", async () => { + await expect(predepositGuarantee.connect(guarantor).topUpNodeOperatorBalance(nodeOperator, { value: ether("2") })) + .to.emit(predepositGuarantee, "BalanceToppedUp") + .withArgs(nodeOperator, guarantor, ether("2")); + + expect(await predepositGuarantee.nodeOperatorBalance(nodeOperator)).to.deep.equal([ether("2"), 0n]); + expect(await predepositGuarantee.claimableRefund(guarantor)).to.equal(0n); + }); + + it("Node Operator assigns a depositor", async () => { + await expect(predepositGuarantee.connect(nodeOperator).setNodeOperatorDepositor(depositor)) + .to.emit(predepositGuarantee, "DepositorSet") + .withArgs(nodeOperator, depositor, nodeOperator); + + expect(await predepositGuarantee.nodeOperatorDepositor(nodeOperator)).to.equal(depositor); + }); + + let validatorHappyPath: Validator; + let validatorFrontrunned: Validator; + + it("Depositor predeposits two validators one valid and frontrunned", async () => { + const { vaultHub } = ctx.contracts; + const withdrawalCredentials = await stakingVault.withdrawalCredentials(); + validatorHappyPath = generateValidator(withdrawalCredentials); + const predepositDataHappyPath = await generatePredeposit(validatorHappyPath, { + depositDomain: await predepositGuarantee.DEPOSIT_DOMAIN(), + }); + + const invalidWithdrawalCredentials = addressToWC(nodeOperator.address); + validatorFrontrunned = generateValidator(invalidWithdrawalCredentials); + + const invalidValidatorHackedWC = { + ...validatorFrontrunned, + container: { + ...validatorFrontrunned.container, + withdrawalCredentials: await stakingVault.withdrawalCredentials(), + }, + }; + + const predepositDataFrontrunned = await generatePredeposit(invalidValidatorHackedWC, { + depositDomain: await predepositGuarantee.DEPOSIT_DOMAIN(), + }); + + const totalValueBefore = await vaultHub.totalValue(stakingVault); + + await expect( + predepositGuarantee + .connect(stranger) + .verifyDepositMessage(predepositDataHappyPath.deposit, predepositDataHappyPath.depositY, withdrawalCredentials), + ).to.not.be.reverted; + + const tx = predepositGuarantee + .connect(depositor) + .predeposit( + stakingVault, + [predepositDataHappyPath.deposit, predepositDataFrontrunned.deposit], + [predepositDataHappyPath.depositY, predepositDataFrontrunned.depositY], + ); + + await expect(tx) + .to.emit(predepositGuarantee, "BalanceLocked") + .withArgs(nodeOperator, ether("2"), ether("2")) + .to.emit(stakingVault, "EtherStaged") + .withArgs(ether("62")) + .to.emit(depositContract, "DepositEvent") + .withArgs( + predepositDataHappyPath.deposit.pubkey, + withdrawalCredentials, + toLittleEndian64(toGwei(predepositDataHappyPath.deposit.amount)), + predepositDataHappyPath.deposit.signature, + anyValue, + ) + .to.emit(depositContract, "DepositEvent") + .withArgs( + predepositDataFrontrunned.deposit.pubkey, + withdrawalCredentials, + toLittleEndian64(toGwei(predepositDataFrontrunned.deposit.amount)), + predepositDataFrontrunned.deposit.signature, + anyValue, + ); + + await expect(tx).changeEtherBalance(stakingVault, -ether("2")); + + await expectPendingPredeposits( + [predepositDataHappyPath.deposit.pubkey, predepositDataFrontrunned.deposit.pubkey], + ether("2"), + ); + expect(await vaultHub.totalValue(stakingVault)).to.equal(totalValueBefore); + }); + + it("Depositor brings a CL proof and tops up the validator", async () => { + const { vaultHub } = ctx.contracts; + const withdrawalCredentials = await stakingVault.withdrawalCredentials(); + const witness = await mockProof(ctx, validatorHappyPath); + + const totalValueBefore = await vaultHub.totalValue(stakingVault); + + const tx = predepositGuarantee.connect(depositor).proveWCAndActivate(witness); + + await expect(tx) + .to.emit(predepositGuarantee, "ValidatorProven") + .withArgs(witness.pubkey, nodeOperator, stakingVault, withdrawalCredentials) + .to.emit(predepositGuarantee, "ValidatorActivated") + .withArgs(witness.pubkey, nodeOperator, stakingVault, withdrawalCredentials) + .to.emit(predepositGuarantee, "BalanceUnlocked") + .withArgs(nodeOperator, ether("2"), ether("1")) + .to.emit(stakingVault, "EtherUnstaged") + .withArgs(ether("31")) + .to.emit(depositContract, "DepositEvent") + .withArgs( + witness.pubkey, + withdrawalCredentials, + toLittleEndian64(toGwei(await predepositGuarantee.ACTIVATION_DEPOSIT_AMOUNT())), + anyValue, + anyValue, + ); + + await expect(tx).changeEtherBalance(stakingVault, -ether("31")); + expect(await predepositGuarantee.pendingActivations(stakingVault)).to.equal(1); + expect((await predepositGuarantee.validatorStatus(witness.pubkey)).stage).to.equal(ValidatorStage.ACTIVATED); + + await expectPendingPredeposits([validatorFrontrunned.container.pubkey], ether("2")); + + expect(await vaultHub.totalValue(stakingVault)).to.equal(totalValueBefore); + }); + + it("Depositor can top up the validator", async () => { + const { vaultHub } = ctx.contracts; + const withdrawalCredentials = await stakingVault.withdrawalCredentials(); + + const totalValueBefore = await vaultHub.totalValue(stakingVault); + + const tx = predepositGuarantee + .connect(depositor) + .topUpExistingValidators([{ pubkey: validatorHappyPath.container.pubkey, amount: ether("1") }]); + + await expect(tx) + .to.emit(depositContract, "DepositEvent") + .withArgs( + validatorHappyPath.container.pubkey, + withdrawalCredentials, + toLittleEndian64(toGwei(ether("1"))), + anyValue, + anyValue, + ); + + await expect(tx).changeEtherBalance(stakingVault, -ether("1")); + await expectPendingPredeposits([validatorFrontrunned.container.pubkey], ether("2")); + expect(await vaultHub.totalValue(stakingVault)).to.equal(totalValueBefore); + }); + + it("Anyone can prove validator being frontrunned and vault will be compensated even if it is disconnected", async () => { + await dashboard.connect(owner).voluntaryDisconnect(); + await reportVaultDataWithProof(ctx, stakingVault, { waitForNextRefSlot: true }); + + const witness = await mockProof(ctx, validatorFrontrunned); + + const tx = predepositGuarantee + .connect(stranger) + .proveInvalidValidatorWC(witness, addressToWC(nodeOperator.address)); + await expect(tx) + .to.emit(predepositGuarantee, "ValidatorCompensated") + .withArgs(stakingVault, nodeOperator, witness.pubkey, ether("1"), ether("0")) + .to.emit(stakingVault, "EtherUnstaged") + .withArgs(ether("31")); + + await expect(tx).changeEtherBalance(stakingVault, ether("1")); + + expect(await predepositGuarantee.nodeOperatorBalance(nodeOperator)).to.deep.equal([ether("1"), ether("0")]); + expect(await stakingVault.stagedBalance()).to.equal(0n); + expect(await predepositGuarantee.pendingActivations(stakingVault)).to.equal(0); + expect((await predepositGuarantee.validatorStatus(witness.pubkey)).stage).to.equal(ValidatorStage.COMPENSATED); + }); + + it("Node Operator can change the guarantor back", async () => { + await expect(predepositGuarantee.connect(nodeOperator).setNodeOperatorGuarantor(nodeOperator)) + .to.emit(predepositGuarantee, "GuarantorSet") + .withArgs(nodeOperator, nodeOperator, guarantor); + + expect(await predepositGuarantee.nodeOperatorGuarantor(nodeOperator)).to.equal(nodeOperator); + expect(await predepositGuarantee.claimableRefund(guarantor)).to.equal(ether("1")); + expect(await predepositGuarantee.nodeOperatorBalance(nodeOperator)).to.deep.equal([ether("0"), ether("0")]); + + const tx = predepositGuarantee.connect(guarantor).claimGuarantorRefund(guarantor); + await expect(tx).to.emit(predepositGuarantee, "GuarantorRefundClaimed").withArgs(guarantor, guarantor, ether("1")); + await expect(tx).changeEtherBalance(guarantor, ether("1")); + + expect(await predepositGuarantee.claimableRefund(guarantor)).to.equal(0n); + }); + + it("Node Operator can change the depositor", async () => { + await expect(predepositGuarantee.connect(nodeOperator).setNodeOperatorDepositor(nodeOperator)) + .to.emit(predepositGuarantee, "DepositorSet") + .withArgs(nodeOperator, nodeOperator, depositor); + + expect(await predepositGuarantee.nodeOperatorDepositor(nodeOperator)).to.equal(nodeOperator); + }); +});