diff --git a/.github/workflows/certora.yml b/.github/workflows/certora.yml new file mode 100644 index 0000000000..3b09ad4428 --- /dev/null +++ b/.github/workflows/certora.yml @@ -0,0 +1,78 @@ +name: Certora CI + +on: + pull_request: + branches: ["develop", "master"] + +env: + FOUNDRY_PROFILE: ci + CONFIGS: | + certora/confs/core/comprehensive-setup.conf + certora/confs/core/Accounting.conf --rule handleOracleReportRevertConditions feesMintShares + certora/confs/core/Accounting-fees-as-frac.conf + certora/confs/lido/Base.conf + certora/confs/lido/Lido_eth.conf + certora/confs/lido/Lido_shares.conf + certora/confs/lido/Lido_staking.conf + certora/confs/misc/burner.conf + certora/confs/misc/node_operators.conf + certora/confs/vaults/VaultHub.conf --rule "disconnectedVaultHasNoLiability" + certora/confs/vaults/VaultHub.conf --rule "disconnectedVaultHasNoLocked" + certora/confs/vaults/VaultHub.conf --rule "tierReserveRatioLeqOne" + certora/confs/vaults/VaultHub.conf --rule "reserveRatioNotBig" + certora/confs/vaults/VaultHub.conf --rule "maxLiabilitySharesGeqLiabilityShares" + certora/confs/vaults/VaultHub.conf --rule "tierReserveRatioGeThreshold" + certora/confs/vaults/VaultHub.conf --rule "vaultReserveRatioGeThreshold" + certora/confs/vaults/VaultHub.conf --rule "redemptionSharesLeqLiabilityShares" + certora/confs/vaults/VaultHub.conf --rule "pendingHasNoShares" + certora/confs/vaults/VaultHub.conf --rule "canIncreaseTotalValue" + certora/confs/vaults/VaultHub.conf --rule "redemptionsIncrease" + certora/confs/vaults/VaultHub.conf --rule "disconnectedVaultIsNotPending" + certora/confs/vaults/VaultHub.conf --rule "vaultsArrayIsNeverEmpty" + certora/confs/vaults/VaultHub.conf --rule "vaultToIndexIsCorrect" + certora/confs/vaults/VaultHub_health_part1.conf + certora/confs/vaults/VaultHub_health_part2.conf + certora/confs/vaults/VaultHub_health_part3.conf + certora/confs/vaults/lazy-oracle.conf + certora/confs/vaults/predeposit.conf + certora/confs/vaults/immutable-ratio.conf + certora/confs/vaults/VaultHub-obligatedVaultIsConnected.conf + +jobs: + check: + runs-on: ubuntu-latest + permissions: + contents: read + statuses: write + pull-requests: write + id-token: write + steps: + - name: checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: install nodejs + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: yarn install + run: | + corepack enable + yarn install + + - name: Perform patches + run: ./certora/scripts/patch.sh + + - name: run configs + uses: Certora/certora-run-action@v2 + with: + configurations: ${{ env.CONFIGS }} + cli-release: "beta" + solc-versions: 0.8.25 0.8.9 0.6.12 0.6.11 0.4.24 + solc-remove-version-prefix: "0." + job-name: "Verified Rules" + certora-key: ${{ secrets.CERTORAKEY }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/foundry-fuzz-tests.yml b/.github/workflows/foundry-fuzz-tests.yml new file mode 100644 index 0000000000..5107cfd3bb --- /dev/null +++ b/.github/workflows/foundry-fuzz-tests.yml @@ -0,0 +1,45 @@ +name: Foundry Fuzz Tests + +on: + - pull_request + +jobs: + fuzz-tests: + runs-on: ubuntu-latest + permissions: + contents: read + statuses: write + pull-requests: write + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: install nodejs + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: yarn install + run: | + corepack enable + yarn install + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: nightly + + - name: Install dependencies + run: forge install + + - name: Run VaultHub Locked Covers Liability Invariant Test + run: forge test --match-contract VaultHubLockedInvariantTest -vvv + + - name: Run VaultHub Health Invariant Test + run: forge test --match-contract VaultHubHealthInvariantTest -vvv + + - name: Run VaultHub Shortfall Fuzz Test + run: forge test --match-contract VaultHubShortfallFuzzTest -vvv diff --git a/.prettierignore b/.prettierignore index 68e5788e7d..cd707799a2 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,6 @@ /foundry /contracts +/certora .gitignore .prettierignore diff --git a/certora/README.md b/certora/README.md new file mode 100644 index 0000000000..2b2e6a5559 --- /dev/null +++ b/certora/README.md @@ -0,0 +1,203 @@ +# Certora Formal Verification: Lido Staking Vaults + +This directory contains Certora's formal verification of the Lido protocol, focussing on the Staking Vaults. The verification covers the staking vaults infrastructure, including vault management, operator management, oracle functionality, and core Lido accounting. + +## Directory Structure + +### `specs/` + +The CVL (Certora Verification Language) specification files, organized by domain. + +- `specs/common/`: Shared summaries, ghost variables, and ERC20 specifications used across all specs. + - `ERC20Standard.spec`, `ERC20Params.spec`, `ERC20Storage.spec`: Standard ERC20 properties. + - `erc20-summary.spec`: ERC20 function summaries. + - `lido-storage-ghost.spec`: Ghost variables for tracking Lido state across rules. + - `lido-summaries.spec`: Summaries for Lido functions. + - `StakingRouter-summary.spec`, `WithdrawalQueue-summary.spec`, `smoothen-summary.spec`: Dependency summaries. +- `specs/vaults/`: Vault-level specifications. + - `VaultHub.spec`: Core VaultHub invariants (connectivity, liability bounds, reserve ratios, redemption shares). + - `VaultHub_health.spec`: Vault health preservation across operations. + - `vaults-array.spec`: Proves the vaults array is a proper set with correct index mappings. + - `predeposit.spec`: Validator predeposit state machine transitions. + - `lazy-oracle.spec`: LazyOracle quarantine integrity and state consistency. + - `shortfall.spec`: Vault shortfall analysis. + - `immutable-ratio.spec`: Analysis assuming constant share-to-ETH ratio. + - `approximated-VaultHub.spec`: Approximated VaultHub analysis. + - `lido-mock.spec`: Lido mock summaries for share-ETH conversions. +- `specs/core/`: Core protocol specifications. + - `Accounting.spec`: Accounting oracle report integrity, fee calculations, and revert conditions. + - `Accounting-burnlimit.spec`: Positive token rebase limiter burn limits. + - `Accounting-fees-as-frac.spec`: Fee calculations as fractional amounts. + - `Accounting-summarized.spec`: Summarized accounting verification. + - `Lido_and_VaultHub.spec`: Integration properties between Lido and VaultHub. + - `comprehensive-setup.spec`: Full integration of Lido, VaultHub, and Accounting. +- `specs/lido/`: Lido contract specifications. + - `Lido.spec`: Share transitions, buffered ETH accounting, staking limits, and access control. +- `specs/misc/`: Miscellaneous contract specifications. + - `burner.spec`: Burner contract invariants and share burning integrity. + - `node_operators.spec`: Node operators registry properties. +- `specs/setup/`: Sanity checks and dispatching specs for component setup verification. + - `sanity_*.spec`: Sanity check specifications per contract. + - `dispatching_*.spec`: Method dispatching specifications per contract. + - `snippet_*.spec`: Snippet specifications for specific components. + +### `confs/` + +Certora Prover configuration files, organized to mirror the spec structure. + +- `confs/vaults/`: Vault-related configurations including partitioned health checks (`VaultHub_health_part1.conf` through `VaultHub_health_part6.conf`). +- `confs/core/`: Core protocol configurations for Accounting and integrated Lido+VaultHub verification. +- `confs/lido/`: Lido-specific configurations (`Base.conf`, `Lido_shares.conf`, `Lido_eth.conf`, `Lido_staking.conf`). +- `confs/misc/`: Burner and node operators configurations. +- `confs/setup/`: Sanity and autocvl configurations. + +### `harness/` + +Harness contracts that extend the original contracts to expose internal state and helper functions for verification. + +- `VaultHubHarness.sol`: Exposes VaultHub internal state including vault records, connections, deltas, and calculations. +- `LidoHarness.sol`: Exposes Lido share rate calculations and storage. +- `AccountingHarness.sol`: Exposes Accounting contract internals. +- `LazyOracleHarness.sol`: Exposes LazyOracle quarantine info and vault data. +- `BurnerHarness.sol`: Exposes Burner contract state. +- `NativeTransferFuncs.sol`: Utility functions for native ETH transfers. + +### `mocks/` + +Mock implementations of external dependencies for isolated verification. + +- `ILidoMock.sol`: Mock Lido interface with share-to-ETH conversion helpers. +- `IHashConsensusMock.sol`: Mock beacon chain consensus contract. +- `IDepositContractMock.sol`: Mock Ethereum 2.0 deposit contract. +- `StorageExtension*.sol`: Storage extensions enabling property verification on internal storage slots for VaultHub, LazyOracle, OperatorGrid, StakingVault, PredepositGuarantee, and NodeOperatorsRegistry. + +### `patches/` + +Code modification scripts and patch files. These make internal functions accessible to the Prover by widening their visibility. + +- `patch.sh` / `patch-undo.sh` (via `scripts/`): Apply and revert all patches. +- `Makefile`: Build automation for patch application. +- `patch-strategy-lib.patch`: Changes `MinFirstAllocationStrategy.allocate()` visibility for Certora access. +- `patch-total-shares-access.patch`: Adds storage access helpers for total shares. + +> [!NOTE] +> The patches are applied as git patches and must be kept in sync with the source code. If the patched files change, the patch scripts will fail to apply and verification will not run. + +## Certora Prover + +The Certora Prover is a formal verification tool for smart contracts. It statically proves or disproves properties expressed as rules and invariants in the Certora Verification Language (CVL). + +## Running Instructions + +0. Install the latest Certora Prover by following the [installation guide](https://docs.certora.com/en/latest/docs/user-guide/install.html). + +1. From the repository root, apply the patches: + + ```sh + sh certora/scripts/patch.sh + ``` + + This only needs to be done once per working copy. **Do not commit the patched files.** + +2. Run the desired verification job from the repository root (see table below for all properties). Example: + + ```sh + certoraRun certora/confs/vaults/VaultHub.conf + ``` + +3. To revert patches: + + ```sh + sh certora/scripts/patch-undo.sh + ``` + +## High-Level Properties + +See the doc-comments in each spec file for detailed descriptions of individual rules. + +### VaultHub (`specs/vaults/VaultHub.spec`, `confs/vaults/VaultHub.conf`) + +- **Obligated Vault Is Connected** (`obligatedVaultIsConnected`): a vault with obligations must be connected. +- **Disconnected Vault Has No Liability** (`disconnectedVaultHasNoLiability`): disconnected vaults have zero liability shares. +- **Disconnected Vault Has No Locked** (`disconnectedVaultHasNoLocked`): a vault with locked value must be connected. +- **Vault Locked Covers Liability and Reserve** (`vaultLockedCoversLiabilityAndReserve`): the locked amount of a vault covers its shares and reserve. +- **Reserve Ratio Not Big** (`reserveRatioNotBig`): a vault's reserve ratio is at most 100%. +- **Tier Reserve Ratio Bounded** (`tierReserveRatioLeqOne`): reserve ratio for tiers is at most 100%. +- **Tier Reserve Ratio Exceeds Threshold** (`tierReserveRatioGeThreshold`): for each tier, the reserve ratio is greater than the force rebalance threshold. +- **Vault Reserve Ratio Exceeds Threshold** (`vaultReserveRatioGeThreshold`): for every vault, its reserve ratio is greater than its force rebalance threshold. +- **Max Liability Shares Bound** (`maxLiabilitySharesGeqLiabilityShares`): max liability shares is greater than or equal to liability shares. +- **Redemption Shares Bound** (`redemptionSharesLeqLiabilityShares`): redemption shares are less than or equal to liability shares. +- **Pending Has No Shares** (`pendingHasNoShares`): pending disconnect vaults have no shares. +- **Every Non-Default Tier Has Group** (`everyNonDefaultTierHasGroup`): every non-default tier has a group. +- **Can Increase Total Value** (`canIncreaseTotalValue`): which functions can increase a vault's total value. +- **Redemptions Increase** (`redemptionsIncrease`): fees can only be increased by `applyVaultReport`. + +### Vault Health (`specs/vaults/VaultHub_health.spec`, `confs/vaults/VaultHub_health*.conf`) + +- **Vault Is Healthy Until Report** (`vaultIsHealtyhUntilReport`): a healthy vault remains healthy until a new report is produced, with the exception of settling fees. +- **Summary Correct** (`summaryCorrect`): correctness of summary in terms of functional equivalence. + +### Vaults Array (`specs/vaults/vaults-array.spec`, `confs/vaults/VaultHub.conf`) + +- **Vaults Array Is Never Empty** (`vaultsArrayIsNeverEmpty`): the `vaults` array in `VaultHub` has address 0 at index 0 after initialization. +- **Index to Vault Is Correct** (`indexToVaultIsCorrect`): array index to vault mapping is correct. +- **Vault to Index Is Correct** (`vaultToIndexIsCorrect`): vault to index mapping is correct. +- **Disconnected Vault Is Not Pending** (`disconnectedVaultIsNotPending`): a vault that is pending disconnect must be connected. + +### Predeposit (`specs/vaults/predeposit.spec`, `confs/vaults/predeposit.conf`) + +- **Validator Status Transitions** (`validatorStatusTransitions`): valid state transitions for validator predeposit stages: NONE -> PREDEPOSITED -> PROVEN -> ACTIVATED (or COMPENSATED). + +### Lazy Oracle (`specs/vaults/lazy-oracle.spec`, `confs/vaults/lazy-oracle.conf`) + +- **Quarantine Integrity** (`quarantineIntegrity`): basic integrity for quarantines. +- **Quarantine State Consistency** (`quarantineStateConsistency`): quarantine state consistency. +- **Handle Sanity Checks Revert Conditions** (`handleSanityChecksRevertConditions`): revert conditions for `_handleSanityChecks`. +- **Quarantine Expiry** (`quarantineExpiry`): once a quarantine expires it cannot be reused. + +### Accounting (`specs/core/Accounting.spec`, `confs/core/Accounting.conf`) + +- **Fees Mint Shares** (`feesMintShares`): rewards are shares minted as fees and `Lido` balance increase. +- **Report Not Reverts By Deposit** (`reportNotRevertsByDeposit`): a deposit done after a report was computed but before it was applied will not cause a revert. +- **Report Not Reverts By Submit** (`reportNotRevertsBySubmit`): a `submit` done after a report was computed but before it was applied will not cause a revert. +- **Handle Oracle Report Revert Conditions** (`handleOracleReportRevertConditions`): revert conditions for `handleOracleReport`. + +### Accounting - Burn Limit (`specs/core/Accounting-burnlimit.spec`, `confs/core/Accounting-burnlimit.conf`) + +- **Burn Limit Integrity**: positive token rebase limiter burn limits are correctly enforced. + +### Lido and VaultHub Integration (`specs/core/Lido_and_VaultHub.spec`, `confs/core/Lido_and_VaultHub.conf`) + +- **Only Called By VaultHub** (`verifyOnlyCalledByVaultHub`): verifies the `Lido` functions that can only be called by `VaultHub`. +- **Only Called By Accounting** (`verifyOnlyCalledByAccounting`): verifies functions that can only be called by `Accounting`. +- **Disconnected Vault Has No Liability** (`disconnectedVaultHasNoLiability`): disconnected vaults have no liability shares. +- **External Shares At Most Sum Liability Shares** (`externalSharesAtMostSumLiabilityShares`): external shares are at most the sum of liability shares plus internalized bad debt. +- **External Shares Liability Shares Change Together** (`externalSharesLiabilitySharesChangeTogether`): external shares and liability shares increase/decrease together. + +### Lido (`specs/lido/Lido.spec`, `confs/lido/`) + +- **Buffered ETH Backed By Balance** (`bufferedEthBackedByBalance`): buffered ETH is backed by contract balance. +- **Shares Transition** (`sharesTransition`): relations between total, external, and internal shares. +- **Total Shares Change Control** (`totalSharesCanOnlyBeChangedBy`): determines the functions that can increase or decrease total shares. +- **Buffered ETH Change Control** (`bufferedEthCanOnlyBeChangedBy`): determines the functions that can change the buffered ETH. +- **Deposited Validators Only Increasing** (`depositedValidatorsOnlyIncreasing`): deposited validators count only increases. +- **Staking Limits Are Kept** (`stakingLimitsAreKept`): internal ETH and shares increase cannot violate the staking limits. +- **Staking Limits Unchanged If Staking** (`stakingLimitsUnchangedIfStaking`): staking limits cannot change in the same function that stakes. +- **Previous Staking Block Number Increasing** (`prevStakingBlockNumberIncreasing`): previous staking block number is weakly monotonically increasing. + +### Burner (`specs/misc/burner.spec`, `confs/misc/burner.conf`) + +- **Burner Does Not Approve** (`burnerDoesNotApprove`): the `Burner` contract gives no allowance to any address. +- **Burner Shares Only Burnt** (`burnerSharesOnlyBurnt`): `Burner` shares can only be reduced by burning (excluding excess shares). +- **Burner Does Not Affect Third-Party Shares** (`burnerDoesNotAffectThirdPartyShares`): burner does not affect unrelated parties' shares. +- **Burn Requests Integrity** (`burnRequestsIntegrity`): integrity of request burn methods. +- **Commit Burn Integrity** (`commitBurnIntergrity`): integrity of `commitSharesToBurn`. + +## General Assumptions + +- **Loop unrolling.** All specs use `optimistic_loop: true`. `loop_iter` is set to 2 to keep verification tractable. +- **Optimistic fallback.** Specs use `optimistic_fallback: true` for calls to unresolved external functions. +- **Optimistic hashing.** `optimistic_hashing: true` is used to simplify hash-related reasoning. +- **Hashing length bound.** `hashing_length_bound` ranges from 98 to 500 depending on the configuration. +- **Summarization.** Complex operations (BLS, SSZ, cryptography) are summarized as `NONDET` for tractability. +- **Partitioned verification.** Complex specs like `VaultHub_health` are split into multiple parts (`part1` through `part6`) to manage solver complexity. diff --git a/certora/confs/core/Accounting-burnlimit.conf b/certora/confs/core/Accounting-burnlimit.conf new file mode 100644 index 0000000000..f6c88fa5ad --- /dev/null +++ b/certora/confs/core/Accounting-burnlimit.conf @@ -0,0 +1,66 @@ +{ + "files": [ + "certora/harness/LidoHarness.sol", + "certora/harness/AccountingHarness.sol", + "contracts/0.8.9/sanity_checks/OracleReportSanityChecker.sol", + "contracts/0.8.9/Burner.sol", + "contracts/0.8.9/LidoLocator.sol", + "certora/mocks/WithdrawalQueueMock.sol" + ], + "link": [ + "AccountingHarness:LIDO_LOCATOR=LidoLocator", + "AccountingHarness:LIDO=LidoHarness", + "Burner:LIDO=LidoHarness", + "Burner:LOCATOR=LidoLocator", + "LidoLocator:accounting=AccountingHarness", + "LidoLocator:withdrawalQueue=WithdrawalQueueMock", + "LidoLocator:burner=Burner" + ], + "packages": [ + "@aragon/minime=node_modules/@aragon/minime", + "@aragon/apps-agent=node_modules/@aragon/apps-agent", + "@aragon/id=node_modules/@aragon/id", + "@openzeppelin/contracts-v5.2=node_modules/@openzeppelin/contracts-v5.2", + "solhint=node_modules/solhint", + "openzeppelin-solidity=node_modules/openzeppelin-solidity", + "@aragon/os=node_modules/@aragon/os", + "@aragon/apps-finance=node_modules/@aragon/apps-finance", + "@openzeppelin/contracts=node_modules/@openzeppelin/contracts", + "@aragon/apps-lido=node_modules/@aragon/apps-lido", + "@openzeppelin/contracts-v4.4=node_modules/@openzeppelin/contracts-v4.4", + "solhint-plugin-lido=node_modules/solhint-plugin-lido", + "@aragon/apps-vault=node_modules/@aragon/apps-vault", + "@openzeppelin/merkle-tree=node_modules/@openzeppelin/merkle-tree" + ], + "server": "prover", + "global_timeout": "7200", + "smt_timeout": "7200", + "build_cache": true, + "optimistic_fallback": true, + "optimistic_hashing": true, + "hashing_length_bound": "98", + "optimistic_loop": true, + "loop_iter": "2", + // NOTE. The compilation parameters are from `hardhat.config.ts` + "compiler_map": { + "LidoHarness": "solc4.24", + "AccountingHarness": "solc8.9", + "Burner": "solc8.9", + "OracleReportSanityChecker": "solc8.9", + "WithdrawalQueueMock": "solc8.9", + "LidoLocator": "solc8.9" + }, + "solc_evm_version_map": { + "LidoHarness": "constantinople", + "AccountingHarness": "istanbul", + "Burner": "istanbul", + "OracleReportSanityChecker": "istanbul", + "WithdrawalQueueMock": "istanbul", + "LidoLocator": "istanbul" + }, + "solc_optimize": "200", + "solc_via_ir": false, + "rule_sanity": "basic", + "verify": "AccountingHarness:certora/specs/core/Accounting-burnlimit.spec", + "msg": "verify Accounting burn limit" +} diff --git a/certora/confs/core/Accounting-fees-as-frac.conf b/certora/confs/core/Accounting-fees-as-frac.conf new file mode 100644 index 0000000000..1cb569fd76 --- /dev/null +++ b/certora/confs/core/Accounting-fees-as-frac.conf @@ -0,0 +1,39 @@ +{ + "files": [ + "certora/harness/AccountingHarness.sol" + ], + "packages": [ + "@aragon/minime=node_modules/@aragon/minime", + "@aragon/apps-agent=node_modules/@aragon/apps-agent", + "@aragon/id=node_modules/@aragon/id", + "@openzeppelin/contracts-v5.2=node_modules/@openzeppelin/contracts-v5.2", + "solhint=node_modules/solhint", + "openzeppelin-solidity=node_modules/openzeppelin-solidity", + "@aragon/os=node_modules/@aragon/os", + "@aragon/apps-finance=node_modules/@aragon/apps-finance", + "@openzeppelin/contracts=node_modules/@openzeppelin/contracts", + "@aragon/apps-lido=node_modules/@aragon/apps-lido", + "@openzeppelin/contracts-v4.4=node_modules/@openzeppelin/contracts-v4.4", + "solhint-plugin-lido=node_modules/solhint-plugin-lido", + "@aragon/apps-vault=node_modules/@aragon/apps-vault", + "@openzeppelin/merkle-tree=node_modules/@openzeppelin/merkle-tree" + ], + "server": "prover", + "global_timeout": "7200", + "smt_timeout": "7200", + "build_cache": true, + "optimistic_fallback": true, + "optimistic_hashing": true, + "hashing_length_bound": "98", + "optimistic_loop": true, + "loop_iter": "2", + // NOTE. The compilation parameters are from `hardhat.config.ts` + "solc": "solc8.9", + "solc_evm_version": "istanbul", + "solc_optimize": "200", + "solc_via_ir": false, + "rule_sanity": "basic", + "disallow_internal_function_calls": true, + "verify": "AccountingHarness:certora/specs/core/Accounting-fees-as-frac.spec", + "msg": "verify Accounting _calculateTotalProtocolFeeShares" +} diff --git a/certora/confs/core/Accounting-summarized.conf b/certora/confs/core/Accounting-summarized.conf new file mode 100644 index 0000000000..0a2b35c695 --- /dev/null +++ b/certora/confs/core/Accounting-summarized.conf @@ -0,0 +1,77 @@ +{ + "files": [ + "certora/harness/LidoHarness.sol", + "certora/harness/AccountingHarness.sol", + "contracts/0.8.9/Burner.sol", + "contracts/0.8.9/sanity_checks/OracleReportSanityChecker.sol", + "contracts/0.8.9/LidoLocator.sol", + "contracts/0.8.9/LidoExecutionLayerRewardsVault.sol", + "contracts/0.8.9/WithdrawalVault.sol", + "certora/mocks/WithdrawalQueueMock.sol" + ], + "link": [ + "AccountingHarness:LIDO_LOCATOR=LidoLocator", + "AccountingHarness:LIDO=LidoHarness", + "Burner:LIDO=LidoHarness", + "Burner:LOCATOR=LidoLocator", + "LidoLocator:accounting=AccountingHarness", + "LidoLocator:burner=Burner", + "LidoLocator:elRewardsVault=LidoExecutionLayerRewardsVault", + "LidoLocator:withdrawalVault=WithdrawalVault", + "LidoLocator:withdrawalQueue=WithdrawalQueueMock", + "LidoExecutionLayerRewardsVault:LIDO=LidoHarness", + "WithdrawalVault:LIDO=LidoHarness" + ], + "packages": [ + "@aragon/minime=node_modules/@aragon/minime", + "@aragon/apps-agent=node_modules/@aragon/apps-agent", + "@aragon/id=node_modules/@aragon/id", + "@openzeppelin/contracts-v5.2=node_modules/@openzeppelin/contracts-v5.2", + "solhint=node_modules/solhint", + "openzeppelin-solidity=node_modules/openzeppelin-solidity", + "@aragon/os=node_modules/@aragon/os", + "@aragon/apps-finance=node_modules/@aragon/apps-finance", + "@openzeppelin/contracts=node_modules/@openzeppelin/contracts", + "@aragon/apps-lido=node_modules/@aragon/apps-lido", + "@openzeppelin/contracts-v4.4=node_modules/@openzeppelin/contracts-v4.4", + "solhint-plugin-lido=node_modules/solhint-plugin-lido", + "@aragon/apps-vault=node_modules/@aragon/apps-vault", + "@openzeppelin/merkle-tree=node_modules/@openzeppelin/merkle-tree" + ], + "server": "prover", + "global_timeout": "7200", + "smt_timeout": "7200", + "build_cache": true, + "optimistic_fallback": true, + "optimistic_hashing": true, + "hashing_length_bound": "98", + "optimistic_loop": true, + "loop_iter": "1", // Reduced to 1 to reduce timeouts + // NOTE. The compilation parameters are from `hardhat.config.ts` + "compiler_map": { + "LidoHarness": "solc4.24", + "AccountingHarness": "solc8.9", + "Burner": "solc8.9", + "OracleReportSanityChecker": "solc8.9", + "LidoLocator": "solc8.9", + "LidoExecutionLayerRewardsVault": "solc8.9", + "WithdrawalVault": "solc8.9", + "WithdrawalQueueMock": "solc8.9" + }, + "solc_evm_version_map": { + "LidoHarness": "constantinople", + "AccountingHarness": "istanbul", + "Burner": "istanbul", + "OracleReportSanityChecker": "istanbul", + "LidoLocator": "istanbul", + "LidoExecutionLayerRewardsVault": "istanbul", + "WithdrawalVault": "istanbul", + "WithdrawalQueueMock": "istanbul" + }, + "solc_via_ir": false, + "solc_optimize": "200", + "parametric_contracts": ["AccountingHarness"], + "rule_sanity": "basic", + "verify": "AccountingHarness:certora/specs/core/Accounting-summarized.spec", + "msg": "verify Accounting summarized" +} diff --git a/certora/confs/core/Accounting-unsummarized.conf b/certora/confs/core/Accounting-unsummarized.conf new file mode 100644 index 0000000000..d893fa064b --- /dev/null +++ b/certora/confs/core/Accounting-unsummarized.conf @@ -0,0 +1,86 @@ +{ + "files": [ + "certora/harness/LidoHarness.sol", + "certora/harness/AccountingHarness.sol", + "contracts/0.8.9/Burner.sol", + "contracts/0.8.9/sanity_checks/OracleReportSanityChecker.sol", + "contracts/0.8.9/LidoLocator.sol", + "contracts/0.8.9/LidoExecutionLayerRewardsVault.sol", + "contracts/0.8.9/WithdrawalVault.sol", + "certora/mocks/WithdrawalQueueMock.sol" + ], + "link": [ + "AccountingHarness:LIDO_LOCATOR=LidoLocator", + "AccountingHarness:LIDO=LidoHarness", + "Burner:LIDO=LidoHarness", + "Burner:LOCATOR=LidoLocator", + "LidoLocator:accounting=AccountingHarness", + "LidoLocator:burner=Burner", + "LidoLocator:elRewardsVault=LidoExecutionLayerRewardsVault", + "LidoLocator:withdrawalVault=WithdrawalVault", + "LidoLocator:withdrawalQueue=WithdrawalQueueMock", + "LidoExecutionLayerRewardsVault:LIDO=LidoHarness", + "WithdrawalVault:LIDO=LidoHarness" + ], + "packages": [ + "@aragon/minime=node_modules/@aragon/minime", + "@aragon/apps-agent=node_modules/@aragon/apps-agent", + "@aragon/id=node_modules/@aragon/id", + "@openzeppelin/contracts-v5.2=node_modules/@openzeppelin/contracts-v5.2", + "solhint=node_modules/solhint", + "openzeppelin-solidity=node_modules/openzeppelin-solidity", + "@aragon/os=node_modules/@aragon/os", + "@aragon/apps-finance=node_modules/@aragon/apps-finance", + "@openzeppelin/contracts=node_modules/@openzeppelin/contracts", + "@aragon/apps-lido=node_modules/@aragon/apps-lido", + "@openzeppelin/contracts-v4.4=node_modules/@openzeppelin/contracts-v4.4", + "solhint-plugin-lido=node_modules/solhint-plugin-lido", + "@aragon/apps-vault=node_modules/@aragon/apps-vault", + "@openzeppelin/merkle-tree=node_modules/@openzeppelin/merkle-tree" + ], + "server": "prover", + "global_timeout": "7200", + "smt_timeout": "7200", + "build_cache": true, + "optimistic_fallback": true, + "optimistic_hashing": true, + "hashing_length_bound": "98", + "optimistic_loop": true, + "loop_iter": "2", + "compiler_map": { + "LidoHarness": "solc4.24", + "AccountingHarness": "solc8.9", + "Burner": "solc8.9", + "OracleReportSanityChecker": "solc8.9", + "LidoLocator": "solc8.9", + "LidoExecutionLayerRewardsVault": "solc8.9", + "WithdrawalVault": "solc8.9", + "WithdrawalQueueMock": "solc8.9" + }, + "solc_optimize": "1000", + "solc_via_ir": false, + /* + "solc_via_ir_map": { + "LidoHarness": false, // The `via_ir` option did not exist in solc 0.4.24 + // Compiling `Accounting` (and some others) without via-ir prevents the error below: + // solc8.9 had an error: InternalCompilerError: Invalid stack item name: slot + "AccountingHarness": false, + "OracleReportSanityChecker": false, + "WithdrawalQueueMock": false, + "Burner": false, + "LidoExecutionLayerRewardsVault": true, + "WithdrawalVault": true, + "LidoLocator": true + }, + "prover_args": [ + "-splitParallel", + "true", + "-dontStopAtFirstSplitTimeout", + "true" + ], + */ + "parametric_contracts": ["AccountingHarness"], + "rule_sanity": "basic", + "verify": "AccountingHarness:certora/specs/core/Accounting-unsummarized.spec", + "msg": "Accounting examples unsummarized" +} diff --git a/certora/confs/core/Accounting.conf b/certora/confs/core/Accounting.conf new file mode 100644 index 0000000000..f3d0b1e220 --- /dev/null +++ b/certora/confs/core/Accounting.conf @@ -0,0 +1,89 @@ +{ + "files": [ + "certora/harness/LidoHarness.sol", + "certora/harness/AccountingHarness.sol", + "contracts/0.8.9/Burner.sol", + "contracts/0.8.9/sanity_checks/OracleReportSanityChecker.sol", + "contracts/0.8.9/LidoLocator.sol", + "contracts/0.8.9/LidoExecutionLayerRewardsVault.sol", + "contracts/0.8.9/WithdrawalVault.sol", + "certora/mocks/WithdrawalQueueMock.sol" + ], + "link": [ + "AccountingHarness:LIDO_LOCATOR=LidoLocator", + "AccountingHarness:LIDO=LidoHarness", + "Burner:LIDO=LidoHarness", + "Burner:LOCATOR=LidoLocator", + "LidoLocator:accounting=AccountingHarness", + "LidoLocator:burner=Burner", + "LidoLocator:elRewardsVault=LidoExecutionLayerRewardsVault", + "LidoLocator:withdrawalVault=WithdrawalVault", + "LidoLocator:withdrawalQueue=WithdrawalQueueMock", + "LidoExecutionLayerRewardsVault:LIDO=LidoHarness", + "LidoLocator:oracleReportSanityChecker=OracleReportSanityChecker", + "WithdrawalVault:LIDO=LidoHarness" + ], + "packages": [ + "@aragon/minime=node_modules/@aragon/minime", + "@aragon/apps-agent=node_modules/@aragon/apps-agent", + "@aragon/id=node_modules/@aragon/id", + "@openzeppelin/contracts-v5.2=node_modules/@openzeppelin/contracts-v5.2", + "solhint=node_modules/solhint", + "openzeppelin-solidity=node_modules/openzeppelin-solidity", + "@aragon/os=node_modules/@aragon/os", + "@aragon/apps-finance=node_modules/@aragon/apps-finance", + "@openzeppelin/contracts=node_modules/@openzeppelin/contracts", + "@aragon/apps-lido=node_modules/@aragon/apps-lido", + "@openzeppelin/contracts-v4.4=node_modules/@openzeppelin/contracts-v4.4", + "solhint-plugin-lido=node_modules/solhint-plugin-lido", + "@aragon/apps-vault=node_modules/@aragon/apps-vault", + "@openzeppelin/merkle-tree=node_modules/@openzeppelin/merkle-tree" + ], + "server": "prover", + "global_timeout": "7200", + "smt_timeout": "7200", + "disallow_internal_function_calls": true, // speedup compilation + "build_cache": true, + "optimistic_fallback": true, + "optimistic_hashing": true, + "hashing_length_bound": "98", + "optimistic_loop": true, + "loop_iter": "2", + "compiler_map": { + "LidoHarness": "solc4.24", + "AccountingHarness": "solc8.9", + "Burner": "solc8.9", + "OracleReportSanityChecker": "solc8.9", + "LidoLocator": "solc8.9", + "LidoExecutionLayerRewardsVault": "solc8.9", + "WithdrawalVault": "solc8.9", + "WithdrawalQueueMock": "solc8.9" + }, + "solc_optimize": "1000", + "solc_via_ir": false, + /* + "solc_via_ir_map": { + "LidoHarness": false, // The `via_ir` option did not exist in solc 0.4.24 + // Compiling `Accounting` (and some others) without via-ir prevents the error below: + // solc8.9 had an error: InternalCompilerError: Invalid stack item name: slot + "AccountingHarness": false, + "OracleReportSanityChecker": false, + "WithdrawalQueueMock": false, + "Burner": false, + "LidoExecutionLayerRewardsVault": true, + "WithdrawalVault": true, + "LidoLocator": true + }, + *. + /* + "prover_args": [ + "-allowArrayLengthUpdates true", + "-cvlFunctionRevert false", + "-summaryResolutionMode aggressive" + ], + */ + "parametric_contracts": ["AccountingHarness"], + "rule_sanity": "basic", + "verify": "AccountingHarness:certora/specs/core/Accounting.spec", + "msg": "verify Accounting" +} diff --git a/certora/confs/core/Lido_and_VaultHub.conf b/certora/confs/core/Lido_and_VaultHub.conf new file mode 100644 index 0000000000..fcf9570be3 --- /dev/null +++ b/certora/confs/core/Lido_and_VaultHub.conf @@ -0,0 +1,125 @@ +{ + "files": [ + "certora/harness/LidoHarness.sol", + "contracts/0.8.9/LidoLocator.sol", + "contracts/0.8.9/Accounting.sol", + "contracts/0.8.9/Burner.sol", + "contracts/0.8.9/LidoExecutionLayerRewardsVault.sol", + "contracts/0.8.9/WithdrawalVault.sol", + "certora/harness/VaultHubHarness.sol", + "contracts/0.8.25/vaults/predeposit_guarantee/PredepositGuarantee.sol", + "contracts/0.8.25/vaults/OperatorGrid.sol", + "contracts/0.8.25/vaults/StakingVault.sol", // NOTE: Can add more StakingVaults for comprehensive testing + "contracts/common/lib/BLS.sol:BLS12_381", + "certora/mocks/IHashConsensusMock.sol", + "certora/mocks/PayableMock.sol", + "certora/mocks/StorageExtension.sol", + "certora/mocks/WithdrawableRequestMock.sol" + ], + "link": [ + "Accounting:LIDO_LOCATOR=LidoLocator", + "Accounting:LIDO=LidoHarness", + "Burner:LOCATOR=LidoLocator", + "Burner:LIDO=LidoHarness", + "LidoLocator:predepositGuarantee=PredepositGuarantee", + "LidoLocator:vaultHub=VaultHubHarness", + "LidoLocator:accounting=Accounting", + "LidoLocator:operatorGrid=OperatorGrid", + "LidoLocator:elRewardsVault=LidoExecutionLayerRewardsVault", + "LidoLocator:withdrawalVault=WithdrawalVault", + "LidoExecutionLayerRewardsVault:LIDO=LidoHarness", + "WithdrawalVault:LIDO=LidoHarness", + "OperatorGrid:LIDO_LOCATOR=LidoLocator", + "VaultHubHarness:LIDO=LidoHarness", + "VaultHubHarness:LIDO_LOCATOR=LidoLocator", + "VaultHubHarness:CONSENSUS_CONTRACT=IHashConsensusMock" + ], + "struct_link": [ + "PredepositGuarantee:stakingVault=StakingVault" + ], + "packages": [ + "@aragon/minime=node_modules/@aragon/minime", + "@aragon/apps-agent=node_modules/@aragon/apps-agent", + "@aragon/id=node_modules/@aragon/id", + "@openzeppelin/contracts-v5.2=node_modules/@openzeppelin/contracts-v5.2", + "solhint=node_modules/solhint", + "openzeppelin-solidity=node_modules/openzeppelin-solidity", + "@aragon/os=node_modules/@aragon/os", + "@aragon/apps-finance=node_modules/@aragon/apps-finance", + "@openzeppelin/contracts=node_modules/@openzeppelin/contracts", + "@aragon/apps-lido=node_modules/@aragon/apps-lido", + "@openzeppelin/contracts-v4.4=node_modules/@openzeppelin/contracts-v4.4", + "solhint-plugin-lido=node_modules/solhint-plugin-lido", + "@aragon/apps-vault=node_modules/@aragon/apps-vault", + "@openzeppelin/merkle-tree=node_modules/@openzeppelin/merkle-tree" + ], + "storage_extension_harnesses": [ + "PredepositGuarantee=StorageExtension", + "OperatorGrid=StorageExtension", + "StakingVault=StorageExtension", + "VaultHubHarness=StorageExtension" + ], + "server": "prover", + "global_timeout": "7200", + "smt_timeout": "7200", + "build_cache": true, + "optimistic_fallback": true, + "optimistic_hashing": true, + "hashing_length_bound": "98", + "optimistic_loop": true, + "loop_iter": "2", + "parametric_contracts": [ + "VaultHubHarness", + "LidoHarness", + "Accounting" + ], + "compiler_map": { + "LidoHarness": "solc4.24", + "Burner": "solc8.9", + "Accounting": "solc8.9", + "LidoLocator": "solc8.9", + "LidoExecutionLayerRewardsVault": "solc8.9", + "WithdrawalVault": "solc8.9", + "VaultHubHarness": "solc8.25", + "PredepositGuarantee": "solc8.25", + "LazyOracle": "solc8.25", + "OperatorGrid": "solc8.25", + "StakingVault": "solc8.25", + "IHashConsensusMock": "solc8.25", + "PayableMock": "solc8.25", + "StorageExtension": "solc8.25", + "WithdrawableRequestMock": "solc8.25", + "BLS12_381": "solc8.25" + }, + "solc_optimize": "1000", + "solc_via_ir_map": { + "LidoHarness": false, // The `via_ir` option did not exist in solc 0.4.24 + // Compiling `Burner` and `Accounting` without via-ir prevents the error below: + // solc8.9 had an error: InternalCompilerError: Invalid stack item name: slot + "Burner": false, + "Accounting": false, + "LidoExecutionLayerRewardsVault": true, + "WithdrawalVault": true, + "LidoLocator": true, + "VaultHubHarness": true, + "PredepositGuarantee": true, + "LazyOracle": true, + "OperatorGrid": true, + "StakingVault": true, + "IHashConsensusMock": true, + "PayableMock": true, + "StorageExtension": true, + "WithdrawableRequestMock": true, + "BLS12_381": true + }, + "disallow_internal_function_calls": true, // speedup compilation + // The prover_args are needed to avoid timeout in the following VaultHub functions: + // applyVaultReport, resumeBeaconChainDeposits, settleVaultObligations. + "prover_args": [ + "-split", + "false" + ], + "rule_sanity": "basic", + "verify": "LidoHarness:certora/specs/core/Lido_and_VaultHub.spec", + "msg": "verify joint Lido and VaultHub properties" +} diff --git a/certora/confs/core/comprehensive-setup.conf b/certora/confs/core/comprehensive-setup.conf new file mode 100644 index 0000000000..d62aac19b4 --- /dev/null +++ b/certora/confs/core/comprehensive-setup.conf @@ -0,0 +1,148 @@ +{ + "files": [ + "certora/harness/LidoHarness.sol", + "contracts/0.8.9/LidoLocator.sol", + "contracts/0.8.9/Accounting.sol", + "contracts/0.8.9/Burner.sol", + "contracts/0.8.9/LidoExecutionLayerRewardsVault.sol", + "contracts/0.8.9/WithdrawalVault.sol", + "certora/harness/VaultHubHarness.sol", + "contracts/0.8.25/vaults/predeposit_guarantee/PredepositGuarantee.sol", + "contracts/0.8.25/vaults/OperatorGrid.sol", + "contracts/0.8.25/vaults/StakingVault.sol", // NOTE: Can add more StakingVaults for comprehensive testing + "contracts/common/lib/BLS.sol:BLS12_381", + "certora/mocks/IHashConsensusMock.sol", + "certora/mocks/PayableMock.sol", + "certora/mocks/StorageExtension.sol", + "certora/mocks/WithdrawableRequestMock.sol" + ], + "link": [ + "Accounting:LIDO_LOCATOR=LidoLocator", + "Accounting:LIDO=LidoHarness", + "Burner:LOCATOR=LidoLocator", + "Burner:LIDO=LidoHarness", + "LidoLocator:predepositGuarantee=PredepositGuarantee", + "LidoLocator:vaultHub=VaultHubHarness", + "LidoLocator:accounting=Accounting", + "LidoLocator:operatorGrid=OperatorGrid", + "LidoLocator:elRewardsVault=LidoExecutionLayerRewardsVault", + "LidoLocator:withdrawalVault=WithdrawalVault", + "LidoExecutionLayerRewardsVault:LIDO=LidoHarness", + "WithdrawalVault:LIDO=LidoHarness", + "OperatorGrid:LIDO_LOCATOR=LidoLocator", + "VaultHubHarness:LIDO=LidoHarness", + "VaultHubHarness:LIDO_LOCATOR=LidoLocator", + "VaultHubHarness:CONSENSUS_CONTRACT=IHashConsensusMock" + ], + "struct_link": [ + "PredepositGuarantee:stakingVault=StakingVault" + ], + "packages": [ + "@aragon/minime=node_modules/@aragon/minime", + "@aragon/apps-agent=node_modules/@aragon/apps-agent", + "@aragon/id=node_modules/@aragon/id", + "@openzeppelin/contracts-v5.2=node_modules/@openzeppelin/contracts-v5.2", + "solhint=node_modules/solhint", + "openzeppelin-solidity=node_modules/openzeppelin-solidity", + "@aragon/os=node_modules/@aragon/os", + "@aragon/apps-finance=node_modules/@aragon/apps-finance", + "@openzeppelin/contracts=node_modules/@openzeppelin/contracts", + "@aragon/apps-lido=node_modules/@aragon/apps-lido", + "@openzeppelin/contracts-v4.4=node_modules/@openzeppelin/contracts-v4.4", + "solhint-plugin-lido=node_modules/solhint-plugin-lido", + "@aragon/apps-vault=node_modules/@aragon/apps-vault", + "@openzeppelin/merkle-tree=node_modules/@openzeppelin/merkle-tree" + ], + "storage_extension_harnesses": [ + "PredepositGuarantee=StorageExtension", + "OperatorGrid=StorageExtension", + "StakingVault=StorageExtension", + "VaultHubHarness=StorageExtension" + ], + "server": "prover", + "global_timeout": "7200", + "smt_timeout": "7200", + "build_cache": true, + "disallow_internal_function_calls": true, // speedup compilation + "optimistic_fallback": true, + "optimistic_hashing": true, + "hashing_length_bound": "98", + "optimistic_loop": true, + "loop_iter": "2", + // NOTE. The compilation parameters are from `hardhat.config.ts` + "compiler_map": { + "LidoHarness": "solc4.24", + "Burner": "solc8.9", + "Accounting": "solc8.9", + "LidoLocator": "solc8.9", + "LidoExecutionLayerRewardsVault": "solc8.9", + "WithdrawalVault": "solc8.9", + "VaultHubHarness": "solc8.25", + "PredepositGuarantee": "solc8.25", + "LazyOracle": "solc8.25", + "OperatorGrid": "solc8.25", + "StakingVault": "solc8.25", + "IHashConsensusMock": "solc8.25", + "PayableMock": "solc8.25", + "StorageExtension": "solc8.25", + "WithdrawableRequestMock": "solc8.25", + "BLS12_381": "solc8.25" + }, + "solc_evm_version_map": { + "LidoHarness": "constantinople", + "Burner": "istanbul", + "Accounting": "istanbul", + "LidoLocator": "istanbul", + "LidoExecutionLayerRewardsVault": "istanbul", + "WithdrawalVault": "istanbul", + "VaultHubHarness": "cancun", + "PredepositGuarantee": "cancun", + "LazyOracle": "cancun", + "OperatorGrid": "cancun", + "StakingVault": "cancun", + "IHashConsensusMock": "cancun", + "PayableMock": "cancun", + "StorageExtension": "cancun", + "WithdrawableRequestMock": "cancun", + "BLS12_381": "cancun" + }, + "solc_via_ir_map": { + "LidoHarness": false, + "Burner": false, + "Accounting": false, + "LidoLocator": false, + "LidoExecutionLayerRewardsVault": false, + "WithdrawalVault": false, + "VaultHubHarness": true, + "PredepositGuarantee": true, + "LazyOracle": true, + "OperatorGrid": true, + "StakingVault": true, + "IHashConsensusMock": true, + "PayableMock": true, + "StorageExtension": true, + "WithdrawableRequestMock": true, + "BLS12_381": true + }, + "solc_optimize_map": { + "LidoHarness": "200", + "Burner": "200", + "Accounting": "200", + "LidoLocator": "200", + "LidoExecutionLayerRewardsVault": "200", + "WithdrawalVault": "200", + "VaultHubHarness": "100", + "PredepositGuarantee": "200", + "LazyOracle": "200", + "OperatorGrid": "200", + "StakingVault": "200", + "IHashConsensusMock": "200", + "PayableMock": "200", + "StorageExtension": "200", + "WithdrawableRequestMock": "200", + "BLS12_381": "200" + }, + "rule_sanity": "basic", + "verify": "LidoHarness:certora/specs/core/comprehensive-setup.spec", + "msg": "verify comprehensive setup summary functions" +} diff --git a/certora/confs/lido/Base.conf b/certora/confs/lido/Base.conf new file mode 100644 index 0000000000..4fa288a60b --- /dev/null +++ b/certora/confs/lido/Base.conf @@ -0,0 +1,90 @@ +{ + "files": [ + "certora/harness/LidoHarness.sol", + "contracts/0.8.9/Accounting.sol", + "contracts/0.8.9/sanity_checks/OracleReportSanityChecker.sol", + "contracts/0.8.9/LidoLocator.sol", + "contracts/0.8.9/LidoExecutionLayerRewardsVault.sol", + "contracts/0.8.9/WithdrawalVault.sol", + "contracts/0.8.9/WithdrawalQueueERC721.sol", + "certora/mocks/WithdrawableRequestMock.sol" + ], + "link": [ + "Accounting:LIDO_LOCATOR=LidoLocator", + "Accounting:LIDO=LidoHarness", + "LidoLocator:accounting=Accounting", + "LidoLocator:elRewardsVault=LidoExecutionLayerRewardsVault", + "LidoLocator:withdrawalVault=WithdrawalVault", + "LidoLocator:withdrawalQueue=WithdrawalQueueERC721", + "LidoExecutionLayerRewardsVault:LIDO=LidoHarness", + "WithdrawalVault:LIDO=LidoHarness" + ], + "packages": [ + "@aragon/minime=node_modules/@aragon/minime", + "@aragon/apps-agent=node_modules/@aragon/apps-agent", + "@aragon/id=node_modules/@aragon/id", + "@openzeppelin/contracts-v5.2=node_modules/@openzeppelin/contracts-v5.2", + "solhint=node_modules/solhint", + "openzeppelin-solidity=node_modules/openzeppelin-solidity", + "@aragon/os=node_modules/@aragon/os", + "@aragon/apps-finance=node_modules/@aragon/apps-finance", + "@openzeppelin/contracts=node_modules/@openzeppelin/contracts", + "@aragon/apps-lido=node_modules/@aragon/apps-lido", + "@openzeppelin/contracts-v4.4=node_modules/@openzeppelin/contracts-v4.4", + "solhint-plugin-lido=node_modules/solhint-plugin-lido", + "@aragon/apps-vault=node_modules/@aragon/apps-vault", + "@openzeppelin/merkle-tree=node_modules/@openzeppelin/merkle-tree" + ], + "rule": [ + "canOnlyBeCalledByAccounting", + "verifygetPooledEthBySharesRoundUp", + "verifygetSharesByPooledEthSummary" + ], + "server": "prover", + "prover_version": "master", + "global_timeout": "7200", + "smt_timeout": "7200", + "build_cache": true, + "optimistic_fallback": true, + "optimistic_hashing": true, + "hashing_length_bound": "98", + "optimistic_loop": true, + "disallow_internal_function_calls": true, // speedup compilation + "loop_iter": "2", + // NOTE. The compilation parameters are from `hardhat.config.ts` + "compiler_map": { + "LidoHarness": "solc4.24", + "Accounting": "solc8.9", + "OracleReportSanityChecker": "solc8.9", + "LidoLocator": "solc8.9", + "LidoExecutionLayerRewardsVault": "solc8.9", + "WithdrawalVault": "solc8.9", + "WithdrawalQueueERC721": "solc8.9", + "WithdrawableRequestMock": "solc8.25" + }, + "solc_evm_version_map": { + "LidoHarness": "constantinople", + "Accounting": "istanbul", + "OracleReportSanityChecker": "istanbul", + "LidoLocator": "istanbul", + "LidoExecutionLayerRewardsVault": "istanbul", + "WithdrawalVault": "istanbul", + "WithdrawalQueueERC721": "istanbul", + "WithdrawableRequestMock": "cancun" + }, + "solc_via_ir_map": { + "LidoHarness": false, + "Accounting": false, + "OracleReportSanityChecker": false, + "LidoLocator": false, + "LidoExecutionLayerRewardsVault": false, + "WithdrawalVault": false, + "WithdrawalQueueERC721": false, + "WithdrawableRequestMock": true + }, + "solc_optimize": 200, + "parametric_contracts": ["LidoHarness", "Accounting"], + "rule_sanity": "basic", + "verify": "LidoHarness:certora/specs/lido/Lido.spec", + "msg": "Lido Base" +} diff --git a/certora/confs/lido/Lido_eth.conf b/certora/confs/lido/Lido_eth.conf new file mode 100644 index 0000000000..e16993f1a4 --- /dev/null +++ b/certora/confs/lido/Lido_eth.conf @@ -0,0 +1,15 @@ +{ + "override_base_config": "certora/confs/lido/Base.conf", + "rule": [ + "depositedValidatorsOnlyIncreasing", + "bufferedEthCanOnlyBeChangedBy", + "bufferedEthBackedByBalance" + ], + "build_cache": true, + "optimistic_fallback": true, + "optimistic_hashing": true, + "optimistic_loop": true, + "loop_iter": "2", + "rule_sanity": "basic", + "msg": "Lido: Buffered ETH" +} diff --git a/certora/confs/lido/Lido_shares.conf b/certora/confs/lido/Lido_shares.conf new file mode 100644 index 0000000000..bf6d8045e3 --- /dev/null +++ b/certora/confs/lido/Lido_shares.conf @@ -0,0 +1,14 @@ +{ + "override_base_config": "certora/confs/lido/Base.conf", + "rule": [ + "totalSharesCanOnlyBeChangedBy", + "sharesTransition" + ], + "build_cache": true, + "optimistic_fallback": true, + "optimistic_hashing": true, + "optimistic_loop": true, + "loop_iter": "2", + "rule_sanity": "basic", + "msg": "Lido: Shares" +} \ No newline at end of file diff --git a/certora/confs/lido/Lido_staking.conf b/certora/confs/lido/Lido_staking.conf new file mode 100644 index 0000000000..526b2a0483 --- /dev/null +++ b/certora/confs/lido/Lido_staking.conf @@ -0,0 +1,15 @@ +{ + "override_base_config": "certora/confs/lido/Base.conf", + "rule": [ + "stakingLimitsUnchangedIfStaking", + "stakingLimitsAreKept", + "prevStakingBlockNumberIncreasing" + ], + "build_cache": true, + "optimistic_fallback": true, + "optimistic_hashing": true, + "optimistic_loop": true, + "loop_iter": "2", + "rule_sanity": "basic", + "msg": "Lido: Staking Limits" +} \ No newline at end of file diff --git a/certora/confs/misc/burner.conf b/certora/confs/misc/burner.conf new file mode 100644 index 0000000000..aaf36be6e7 --- /dev/null +++ b/certora/confs/misc/burner.conf @@ -0,0 +1,56 @@ +{ + "files": [ + "certora/harness/LidoHarness.sol", + "certora/harness/BurnerHarness.sol", + "contracts/0.8.9/LidoLocator.sol" + ], + "link": [ + "BurnerHarness:LIDO=LidoHarness", + "BurnerHarness:LOCATOR=LidoLocator", + "LidoLocator:burner=BurnerHarness", + "LidoLocator:lido=LidoHarness" + ], + "server": "prover", + "packages": [ + "@aragon/minime=node_modules/@aragon/minime", + "@aragon/apps-agent=node_modules/@aragon/apps-agent", + "@aragon/id=node_modules/@aragon/id", + "@openzeppelin/contracts-v5.2=node_modules/@openzeppelin/contracts-v5.2", + "solhint=node_modules/solhint", + "openzeppelin-solidity=node_modules/openzeppelin-solidity", + "@aragon/os=node_modules/@aragon/os", + "@aragon/apps-finance=node_modules/@aragon/apps-finance", + "@openzeppelin/contracts=node_modules/@openzeppelin/contracts", + "@aragon/apps-lido=node_modules/@aragon/apps-lido", + "@openzeppelin/contracts-v4.4=node_modules/@openzeppelin/contracts-v4.4", + "solhint-plugin-lido=node_modules/solhint-plugin-lido", + "@aragon/apps-vault=node_modules/@aragon/apps-vault", + "@openzeppelin/merkle-tree=node_modules/@openzeppelin/merkle-tree" + ], + "disallow_internal_function_calls": true, // speedup compilation + "global_timeout": "7200", + "smt_timeout": "7200", + "build_cache": true, + "optimistic_fallback": true, + "optimistic_hashing": true, + "hashing_length_bound": "98", + "optimistic_loop": true, + "loop_iter": "2", + // NOTE. The compilation parameters are from `hardhat.config.ts` + "compiler_map": { + "LidoHarness": "solc4.24", + "BurnerHarness": "solc8.9", + "LidoLocator": "solc8.9" + }, + "solc_evm_version_map": { + "LidoHarness": "constantinople", + "BurnerHarness": "istanbul", + "LidoLocator": "istanbul" + }, + "solc_optimize": "200", + "solc_via_ir": false, + "parametric_contracts": ["BurnerHarness", "LidoHarness"], + "rule_sanity": "basic", + "verify": "BurnerHarness:certora/specs/misc/burner.spec", + "msg": "verify Burner contract" +} diff --git a/certora/confs/misc/node_operators.conf b/certora/confs/misc/node_operators.conf new file mode 100644 index 0000000000..019af8f08a --- /dev/null +++ b/certora/confs/misc/node_operators.conf @@ -0,0 +1,41 @@ +{ + "files": [ + "contracts/0.4.24/nos/NodeOperatorsRegistry.sol" + ], + "packages": [ + "@aragon/minime=node_modules/@aragon/minime", + "@aragon/apps-agent=node_modules/@aragon/apps-agent", + "@aragon/id=node_modules/@aragon/id", + "@openzeppelin/contracts-v5.2=node_modules/@openzeppelin/contracts-v5.2", + "solhint=node_modules/solhint", + "openzeppelin-solidity=node_modules/openzeppelin-solidity", + "@aragon/os=node_modules/@aragon/os", + "@aragon/apps-finance=node_modules/@aragon/apps-finance", + "@openzeppelin/contracts=node_modules/@openzeppelin/contracts", + "@aragon/apps-lido=node_modules/@aragon/apps-lido", + "@openzeppelin/contracts-v4.4=node_modules/@openzeppelin/contracts-v4.4", + "solhint-plugin-lido=node_modules/solhint-plugin-lido", + "@aragon/apps-vault=node_modules/@aragon/apps-vault", + "@openzeppelin/merkle-tree=node_modules/@openzeppelin/merkle-tree" + ], + "disallow_internal_function_calls": true, // speedup compilation + "server": "prover", + "global_timeout": "7200", + "smt_timeout": "7200", + "build_cache": true, + "optimistic_fallback": true, + "optimistic_hashing": true, + "hashing_length_bound": "98", + "optimistic_loop": true, + "loop_iter": "2", + "rule_sanity": "basic", + // NOTE. The compilation parameters are from `hardhat.config.ts` + "compiler_map": { + "NodeOperatorsRegistry": "solc4.24" + }, + "solc_evm_version": "constantinople", + "solc_via_ir": false, + "solc_optimize": "200", + "verify": "NodeOperatorsRegistry:certora/specs/misc/node_operators.spec", + "msg": "verify NodeOperatorsRegistry contract" +} diff --git a/certora/confs/old-Lido.conf b/certora/confs/old-Lido.conf new file mode 100644 index 0000000000..b79dd2e8e6 --- /dev/null +++ b/certora/confs/old-Lido.conf @@ -0,0 +1,141 @@ +{ + // Removed assert_autofinder_success - it fails + // "assert_autofinder_success": true, + "contract_recursion_limit": "1", + "files": [ + "contracts/0.6.11/deposit_contract.sol:DepositContract", + "contracts/0.8.9/sanity_checks/OracleReportSanityChecker.sol", + "contracts/0.8.9/Accounting.sol", + "contracts/0.8.9/oracle/AccountingOracle.sol", + "contracts/0.8.9/Burner.sol", + "contracts/0.8.9/EIP712StETH.sol", + "contracts/0.8.9/LidoExecutionLayerRewardsVault.sol", + "contracts/0.8.9/LidoLocator.sol", + "contracts/0.8.9/StakingRouter.sol", + "contracts/0.8.9/WithdrawalQueueERC721.sol", + "contracts/0.8.9/WithdrawalVault.sol", + "contracts/0.8.25/vaults/VaultHub.sol", + "certora/harness/LidoHarness.sol", + "certora/harness/NativeTransferFuncs.sol", + "certora/mocks/IHashConsensusMock.sol", + "certora/mocks/OldBurnerMock.sol", + "node_modules/@aragon/os/contracts/kernel/Kernel.sol", + "test/0.8.9/contracts/SecondOpinionOracle__Mock.sol" + ], + "link": [ + "Accounting:LIDO=LidoHarness", + "Accounting:LIDO_LOCATOR=LidoLocator", + "Burner:LIDO=LidoHarness", + "Burner:LOCATOR=LidoLocator", + "NativeTransferFuncs:LIDO=LidoHarness", + "LidoLocator:accounting=Accounting", + "LidoLocator:accountingOracle=AccountingOracle", + "LidoLocator:burner=Burner", + "LidoLocator:elRewardsVault=LidoExecutionLayerRewardsVault", + "LidoLocator:oracleReportSanityChecker=OracleReportSanityChecker", + "LidoLocator:stakingRouter=StakingRouter", + "LidoLocator:vaultHub=VaultHub", + "LidoLocator:withdrawalQueue=WithdrawalQueueERC721", + "LidoLocator:withdrawalVault=WithdrawalVault", + "LidoExecutionLayerRewardsVault:LIDO=LidoHarness", + "OracleReportSanityChecker:LIDO_LOCATOR=LidoLocator", + "OracleReportSanityChecker:secondOpinionOracle=SecondOpinionOracle__Mock", + "StakingRouter:DEPOSIT_CONTRACT=DepositContract", + "VaultHub:CONSENSUS_CONTRACT=IHashConsensusMock", + "VaultHub:LIDO_LOCATOR=LidoLocator", + "WithdrawalVault:LIDO=LidoHarness" + ], + "loop_iter": "3", + "msg": "Lido all rules", + "optimistic_fallback": true, + "optimistic_loop": true, + "parametric_contracts": ["LidoHarness"], + "prover_args": [ + "-summaryResolutionMode aggressive", + "-allowArrayLengthUpdates true", + "-cvlFunctionRevert false" + ], + "prover_version": "master", + // NOTE. The compilation parameters are from `hardhat.config.ts` + "compiler_map": { + "DepositContract": "solc6.11", + "OracleReportSanityChecker": "solc8.9", + "Accounting": "solc8.9", + "AccountingOracle": "solc8.9", + "Burner": "solc8.9", + "EIP712StETH": "solc8.9", + "IHashConsensusMock": "solc8.25", + "LidoExecutionLayerRewardsVault": "solc8.9", + "LidoLocator": "solc8.9", + "StakingRouter": "solc8.9", + "WithdrawalQueueERC721": "solc8.9", + "WithdrawalVault": "solc8.9", + "VaultHub": "solc8.25", + "LidoHarness": "solc4.24", + "NativeTransferFuncs": "solc8.9", + "OldBurnerMock": "solc8.9", + "Kernel": "solc4.24", + "SecondOpinionOracle__Mock": "solc8.9" + }, + "solc_evm_version_map": { + "DepositContract": "istanbul", + "OracleReportSanityChecker": "istanbul", + "Accounting": "istanbul", + "AccountingOracle": "istanbul", + "Burner": "istanbul", + "EIP712StETH": "istanbul", + "IHashConsensusMock": "cancun", + "LidoExecutionLayerRewardsVault": "istanbul", + "LidoLocator": "istanbul", + "StakingRouter": "istanbul", + "WithdrawalQueueERC721": "istanbul", + "WithdrawalVault": "istanbul", + "VaultHub": "cancun", + "LidoHarness": "constantinople", + "NativeTransferFuncs": "istanbul", + "OldBurnerMock": "istanbul", + "Kernel": "constantinople", + "SecondOpinionOracle__Mock": "istanbul" + }, + "solc_via_ir_map": { + "DepositContract": false, + "OracleReportSanityChecker": false, + "Accounting": false, + "AccountingOracle": false, + "Burner": false, + "EIP712StETH": false, + "IHashConsensusMock": true, + "LidoExecutionLayerRewardsVault": false, + "LidoLocator": false, + "StakingRouter": false, + "WithdrawalQueueERC721": false, + "WithdrawalVault": false, + "VaultHub": true, + "LidoHarness": false, + "NativeTransferFuncs": false, + "OldBurnerMock": false, + "Kernel": false, + "SecondOpinionOracle__Mock": false + }, + "solc_optimize_map": { + "DepositContract": "200", + "OracleReportSanityChecker": "200", + "Accounting": "200", + "AccountingOracle": "200", + "Burner": "200", + "EIP712StETH": "200", + "IHashConsensusMock": "200", + "LidoExecutionLayerRewardsVault": "200", + "LidoLocator": "200", + "StakingRouter": "200", + "WithdrawalQueueERC721": "200", + "WithdrawalVault": "200", + "VaultHub": "100", + "LidoHarness": "200", + "NativeTransferFuncs": "200", + "OldBurnerMock": "200", + "Kernel": "200", + "SecondOpinionOracle__Mock": "200" + }, + "verify": "LidoHarness:certora/specs/old-Lido.spec" +} diff --git a/certora/confs/old-Steth.conf b/certora/confs/old-Steth.conf new file mode 100644 index 0000000000..6f60f6c0f9 --- /dev/null +++ b/certora/confs/old-Steth.conf @@ -0,0 +1,71 @@ +{ + "assert_autofinder_success": true, + "files": [ + "contracts/0.6.11/deposit_contract.sol:DepositContract", + "contracts/0.8.9/Accounting.sol", + "contracts/0.8.9/Burner.sol", + "contracts/0.8.9/DepositSecurityModule.sol", + "contracts/0.8.9/EIP712StETH.sol", + "contracts/0.8.9/LidoExecutionLayerRewardsVault.sol", + "contracts/0.8.9/LidoLocator.sol", + "contracts/0.8.9/StakingRouter.sol", + "contracts/0.8.9/WithdrawalQueueERC721.sol", + "contracts/0.8.9/WithdrawalVault.sol", + "certora/harness/LidoHarness.sol", + "node_modules/@aragon/os/contracts/kernel/Kernel.sol" + ], + "link": [ + "LidoExecutionLayerRewardsVault:LIDO=LidoHarness", + "LidoLocator:accounting=Accounting", + "LidoLocator:burner=Burner", + "LidoLocator:elRewardsVault=LidoExecutionLayerRewardsVault", + "LidoLocator:depositSecurityModule=DepositSecurityModule", + "LidoLocator:stakingRouter=StakingRouter", + "LidoLocator:withdrawalQueue=WithdrawalQueueERC721", + "LidoLocator:withdrawalVault=WithdrawalVault", + "StakingRouter:DEPOSIT_CONTRACT=DepositContract", + "WithdrawalVault:LIDO=LidoHarness" + ], + "loop_iter": "3", + "msg": "StEth - privilegedOperation", + "optimistic_fallback": true, + "optimistic_loop": true, + "parametric_contracts": ["LidoHarness"], + "prover_args": [ + "-summaryResolutionMode aggressive", + "-allowArrayLengthUpdates true", + "-cvlFunctionRevert false" + ], + "prover_version": "master", + // NOTE. The compilation parameters are from `hardhat.config.ts` + "compiler_map": { + "DepositContract": "solc6.11", + "Accounting": "solc8.9", + "Burner": "solc8.9", + "DepositSecurityModule": "solc8.9", + "EIP712StETH": "solc8.9", + "LidoExecutionLayerRewardsVault": "solc8.9", + "LidoLocator": "solc8.9", + "StakingRouter": "solc8.9", + "WithdrawalQueueERC721": "solc8.9", + "WithdrawalVault": "solc8.9", + "LidoHarness": "solc4.24", + "Kernel": "solc4.24" + }, + "solc_evm_version_map": { + "DepositContract": "istanbul", + "Accounting": "istanbul", + "Burner": "istanbul", + "DepositSecurityModule": "istanbul", + "EIP712StETH": "istanbul", + "LidoExecutionLayerRewardsVault": "istanbul", + "LidoLocator": "istanbul", + "StakingRouter": "istanbul", + "WithdrawalQueueERC721": "istanbul", + "WithdrawalVault": "istanbul", + "LidoHarness": "constantinople", + "Kernel": "constantinople" + }, + "solc_optimize": "200", + "verify": "LidoHarness:certora/specs/old-StEth.spec" +} diff --git a/certora/confs/setup/autocvl_Accounting.conf b/certora/confs/setup/autocvl_Accounting.conf new file mode 100644 index 0000000000..c61689a4bf --- /dev/null +++ b/certora/confs/setup/autocvl_Accounting.conf @@ -0,0 +1,67 @@ +{ + "assert_autofinder_success": true, + "compiler_map": { + "Accounting": "solc8.9", + "AccountingOracle": "solc8.9", + "Burner": "solc8.9", + "LidoLocator": "solc8.9", + "LidoExecutionLayerRewardsVault": "solc8.9", + "StakingRouter": "solc8.9", + "WithdrawalQueueERC721": "solc8.9", + "WithdrawalVault": "solc8.9", + "OracleReportSanityChecker": "solc8.9", + "VaultHub": "solc8.25", + "LidoHarness": "solc4.24", + "IHashConsensusMock": "solc8.25", + "SecondOpinionOracle__Mock": "solc8.9", + }, + "files": [ + "contracts/0.8.9/Accounting.sol", + "contracts/0.8.9/Burner.sol", + "contracts/0.8.9/LidoLocator.sol", + "contracts/0.8.9/LidoExecutionLayerRewardsVault.sol", + "contracts/0.8.9/StakingRouter.sol", + "contracts/0.8.9/WithdrawalQueueERC721.sol", + "contracts/0.8.9/WithdrawalVault.sol", + "contracts/0.8.9/oracle/AccountingOracle.sol", + "contracts/0.8.9/sanity_checks/OracleReportSanityChecker.sol", + "contracts/0.8.25/vaults/VaultHub.sol", + "certora/harness/LidoHarness.sol", + "certora/mocks/IHashConsensusMock.sol", + "test/0.8.9/contracts/SecondOpinionOracle__Mock.sol", + ], + "global_timeout": "7200", + "link": [ + "Accounting:LIDO=LidoHarness", + "Accounting:LIDO_LOCATOR=LidoLocator", + "Burner:LIDO=LidoHarness", + "Burner:LOCATOR=LidoLocator", + "LidoLocator:accounting=Accounting", + "LidoLocator:accountingOracle=AccountingOracle", + "LidoLocator:burner=Burner", + "LidoLocator:elRewardsVault=LidoExecutionLayerRewardsVault", + "LidoLocator:oracleReportSanityChecker=OracleReportSanityChecker", + "LidoLocator:stakingRouter=StakingRouter", + "LidoLocator:vaultHub=VaultHub", + "LidoLocator:withdrawalQueue=WithdrawalQueueERC721", + "LidoLocator:withdrawalVault=WithdrawalVault", + "LidoExecutionLayerRewardsVault:LIDO=LidoHarness", + "OracleReportSanityChecker:LIDO_LOCATOR=LidoLocator", + "OracleReportSanityChecker:secondOpinionOracle=SecondOpinionOracle__Mock", + "VaultHub:CONSENSUS_CONTRACT=IHashConsensusMock", + "VaultHub:LIDO_LOCATOR=LidoLocator", + "WithdrawalVault:LIDO=LidoHarness", + ], + "loop_iter": "2", + "msg": "AutoCVL Accounting", + "optimistic_loop": true, + "parametric_contracts": ["Accounting"], + "prover_args": [ + "-allowArrayLengthUpdates true", + "-cvlFunctionRevert false", + "-summaryResolutionMode aggressive", + ], + "prover_version": "master", + "solc_via_ir": false, + "verify": "Accounting:certora/specs/setup/autocvl_Accounting.spec" +} \ No newline at end of file diff --git a/certora/confs/setup/sanity_Dashboard.conf b/certora/confs/setup/sanity_Dashboard.conf new file mode 100644 index 0000000000..7beb7d507e --- /dev/null +++ b/certora/confs/setup/sanity_Dashboard.conf @@ -0,0 +1,141 @@ +{ + "address": [ + "WithdrawableRequestMock:0x00000961Ef480Eb55e80D19ad83579A64c007002" + ], + // Removed assert_autofinder_success - it fails + // "assert_autofinder_success": true, + "build_cache": true, + "files": [ + "contracts/0.8.25/vaults/dashboard/Dashboard.sol", + "contracts/0.8.25/vaults/predeposit_guarantee/PredepositGuarantee.sol", + "contracts/0.8.25/vaults/LazyOracle.sol", + "contracts/0.8.25/vaults/OperatorGrid.sol", + "contracts/0.8.25/vaults/StakingVault.sol", + "contracts/0.8.25/vaults/VaultHub.sol", + "contracts/0.8.9/LidoLocator.sol", + "contracts/0.6.11/deposit_contract.sol:DepositContract", + "contracts/0.6.12/WstETH.sol", + "contracts/0.4.24/Lido.sol", + "certora/mocks/ERC721RecipientMock.sol", + "certora/mocks/IHashConsensusMock.sol", + "certora/mocks/ILidoMock.sol", + "certora/mocks/PayableMock.sol", + "certora/mocks/WithdrawableRequestMock.sol", + "foundry/lib/forge-std/src/mocks/MockERC721.sol" + ], + "global_timeout": "7200", + "hashing_length_bound": "202", + "link": [ + "Dashboard:VAULT_HUB=VaultHub", + "Dashboard:LIDO_LOCATOR=LidoLocator", + "Dashboard:STETH=Lido", + "Dashboard:WSTETH=WstETH", + "OperatorGrid:LIDO_LOCATOR=LidoLocator", + "LidoLocator:lazyOracle=LazyOracle", + "LidoLocator:lido=ILidoMock", + "LidoLocator:predepositGuarantee=PredepositGuarantee", + "LidoLocator:operatorGrid=OperatorGrid", + "LidoLocator:vaultHub=VaultHub", + "StakingVault:DEPOSIT_CONTRACT=DepositContract", + "VaultHub:LIDO=ILidoMock", + "VaultHub:LIDO_LOCATOR=LidoLocator", + "VaultHub:CONSENSUS_CONTRACT=IHashConsensusMock", + "WstETH:stETH=Lido" + ], + "loop_iter": "2", + "msg": "sanity_Dashboard", + "mutations": { + "gambit": [ + { + "filename": "contracts/0.8.25/vaults/dashboard/Dashboard.sol", + "num_mutants": 5 + } + ] + }, + "optimistic_fallback": true, + "optimistic_hashing": true, + "optimistic_loop": true, + "packages": [ + "@aragon/apps-lido=node_modules/@aragon/apps-lido", + "@aragon/apps-agent=node_modules/@aragon/apps-agent", + "@openzeppelin/contracts-v4.4=node_modules/@openzeppelin/contracts-v4.4", + "@aragon/os=node_modules/@aragon/os", + "@aragon/minime=node_modules/@aragon/minime", + "@openzeppelin/contracts=node_modules/@openzeppelin/contracts", + "openzeppelin-solidity=node_modules/openzeppelin-solidity", + "solhint=node_modules/solhint", + "@aragon/apps-vault=node_modules/@aragon/apps-vault", + "@openzeppelin/contracts-v5.2=node_modules/@openzeppelin/contracts-v5.2", + "@aragon/id=node_modules/@aragon/id", + "@aragon/apps-finance=node_modules/@aragon/apps-finance", + "@openzeppelin/merkle-tree=node_modules/@openzeppelin/merkle-tree", + "solhint-plugin-lido=node_modules/solhint-plugin-lido" + ], + "prover_args": [ + "-verifyCache ", + "-verifyTACDumps", + "-testMode", + "-checkRuleDigest", + "-callTraceHardFail on" + ], + "prover_version": "master", + // NOTE. The compilation parameters are from `hardhat.config.ts` + "compiler_map": { + "Dashboard": "solc8.25", + "PredepositGuarantee": "solc8.25", + "LazyOracle": "solc8.25", + "OperatorGrid": "solc8.25", + "StakingVault": "solc8.25", + "VaultHub": "solc8.25", + "LidoLocator": "solc8.9", + "WstETH": "solc6.12", + "Lido": "solc4.24", + "ERC721RecipientMock": "solc8.25", + "DepositContract": "solc6.11", + "IHashConsensusMock": "solc8.25", + "ILidoMock": "solc8.25", + "PayableMock": "solc8.25", + "WithdrawableRequestMock": "solc8.25", + "MockERC721": "solc8.25" + }, + "solc_optimize_map": { + "Dashboard": "200", + "PredepositGuarantee": "200", + "LazyOracle": "200", + "OperatorGrid": "200", + "StakingVault": "200", + "VaultHub": "100", + "LidoLocator": "200", + "WstETH": "200", + "Lido": "200", + "ERC721RecipientMock": "200", + "DepositContract": "200", + "IHashConsensusMock": "200", + "ILidoMock": "200", + "PayableMock": "200", + "WithdrawableRequestMock": "200", + "MockERC721": "200" + }, + "solc_via_ir_map": { + "Dashboard": true, + "PredepositGuarantee": true, + "LazyOracle": true, + "OperatorGrid": true, + "StakingVault": true, + "VaultHub": true, + "LidoLocator": true, + "WstETH": false, + "Lido": false, + "ERC721RecipientMock": true, + "DepositContract": false, + "IHashConsensusMock": true, + "ILidoMock": true, + "PayableMock": true, + "WithdrawableRequestMock": true, + "MockERC721": true + }, + "struct_link": [ + "PredepositGuarantee:stakingVault=StakingVault" + ], + "verify": "Dashboard:certora/specs/setup/sanity_Dashboard.spec" +} diff --git a/certora/confs/setup/sanity_LazyOracle.conf b/certora/confs/setup/sanity_LazyOracle.conf new file mode 100644 index 0000000000..9c3e02b60e --- /dev/null +++ b/certora/confs/setup/sanity_LazyOracle.conf @@ -0,0 +1,136 @@ +{ // Removed assert_autofinder_success - it fails + // "assert_autofinder_success": true, + "build_cache": true, + "files": [ + "contracts/0.8.25/vaults/LazyOracle.sol", + "contracts/0.8.25/vaults/OperatorGrid.sol", + "contracts/0.8.25/vaults/StakingVault.sol", + "contracts/0.8.25/vaults/VaultHub.sol", + "contracts/0.8.9/oracle/AccountingOracle.sol", + "contracts/0.8.9/LidoLocator.sol", + "certora/mocks/IHashConsensusMock.sol", + "certora/mocks/ILidoMock.sol", + "certora/mocks/PayableMock.sol", + "certora/mocks/StorageExtensionLazyOracle.sol", + "certora/mocks/StorageExtensionVaultHub.sol", + "certora/mocks/StorageExtensionOperatorGrid.sol", + "certora/mocks/StorageExtensionStakingVault.sol" + ], + "global_timeout": "7200", + "link": [ + "LazyOracle:LIDO_LOCATOR=LidoLocator", + "LidoLocator:accountingOracle=AccountingOracle", + "LidoLocator:operatorGrid=OperatorGrid", + "LidoLocator:lazyOracle=LazyOracle", + "LidoLocator:lido=ILidoMock", + "LidoLocator:vaultHub=VaultHub", + "OperatorGrid:LIDO_LOCATOR=LidoLocator", + "VaultHub:LIDO_LOCATOR=LidoLocator", + "VaultHub:LIDO=ILidoMock", + "VaultHub:CONSENSUS_CONTRACT=IHashConsensusMock" + ], + "loop_iter": "2", + "msg": "sanity_LazyOracle", + "mutations": { + "gambit": [ + { + "filename": "contracts/0.8.25/vaults/LazyOracle.sol", + "num_mutants": 5 + } + ] + }, + "optimistic_fallback": true, + "optimistic_loop": true, + "packages": [ + "@aragon/id=node_modules/@aragon/id", + "@openzeppelin/contracts=node_modules/@openzeppelin/contracts", + "@aragon/os=node_modules/@aragon/os", + "solhint-plugin-lido=node_modules/solhint-plugin-lido", + "@aragon/minime=node_modules/@aragon/minime", + "solhint=node_modules/solhint", + "@openzeppelin/merkle-tree=node_modules/@openzeppelin/merkle-tree", + "@openzeppelin/contracts-v4.4=node_modules/@openzeppelin/contracts-v4.4", + "@aragon/apps-finance=node_modules/@aragon/apps-finance", + "@aragon/apps-vault=node_modules/@aragon/apps-vault", + "@aragon/apps-lido=node_modules/@aragon/apps-lido", + "@openzeppelin/contracts-v5.2=node_modules/@openzeppelin/contracts-v5.2", + "@aragon/apps-agent=node_modules/@aragon/apps-agent", + "openzeppelin-solidity=node_modules/openzeppelin-solidity" + ], + "prover_args": [ + "-verifyCache ", + "-verifyTACDumps", + "-testMode", + "-checkRuleDigest", + "-callTraceHardFail on" + ], + "prover_version": "master", + // NOTE. The compilation parameters are from `hardhat.config.ts` + "compiler_map": { + "LazyOracle": "solc8.25", + "OperatorGrid": "solc8.25", + "StakingVault": "solc8.25", + "VaultHub": "solc8.25", + "AccountingOracle": "solc8.9", + "LidoLocator": "solc8.9", + "IHashConsensusMock": "solc8.25", + "ILidoMock": "solc8.25", + "PayableMock": "solc8.25", + "StorageExtensionLazyOracle": "solc8.25", + "StorageExtensionVaultHub": "solc8.25", + "StorageExtensionOperatorGrid": "solc8.25", + "StorageExtensionStakingVault": "solc8.25" + }, + "solc_evm_version_map": { + "LazyOracle": "cancun", + "OperatorGrid": "cancun", + "StakingVault": "cancun", + "VaultHub": "cancun", + "AccountingOracle": "istanbul", + "LidoLocator": "istanbul", + "IHashConsensusMock": "cancun", + "ILidoMock": "cancun", + "PayableMock": "cancun", + "StorageExtensionLazyOracle": "cancun", + "StorageExtensionVaultHub": "cancun", + "StorageExtensionOperatorGrid": "cancun", + "StorageExtensionStakingVault": "cancun" + }, + "solc_via_ir_map": { + "LazyOracle": true, + "OperatorGrid": true, + "StakingVault": true, + "VaultHub": true, + "AccountingOracle": false, + "LidoLocator": false, + "IHashConsensusMock": true, + "ILidoMock": true, + "PayableMock": true, + "StorageExtensionLazyOracle": true, + "StorageExtensionVaultHub": true, + "StorageExtensionOperatorGrid": true, + "StorageExtensionStakingVault": true + }, + "solc_optimize_map": { + "LazyOracle": "200", + "OperatorGrid": "200", + "StakingVault": "200", + "VaultHub": "100", + "AccountingOracle": "200", + "LidoLocator": "200", + "IHashConsensusMock": "200", + "ILidoMock": "200", + "PayableMock": "200", + "StorageExtensionLazyOracle": "200", + "StorageExtensionVaultHub": "200", + "StorageExtensionOperatorGrid": "200", + "StorageExtensionStakingVault": "200" + }, + "storage_extension_harnesses": [ + "LazyOracle=StorageExtensionLazyOracle", + "OperatorGrid=StorageExtensionOperatorGrid", + "StakingVault=StorageExtensionStakingVault", + "VaultHub=StorageExtensionVaultHub" + ], + "verify": "LazyOracle:certora/specs/setup/sanity_LazyOracle.spec" +} diff --git a/certora/confs/setup/sanity_OperatorGrid.conf b/certora/confs/setup/sanity_OperatorGrid.conf new file mode 100644 index 0000000000..f3737ebb05 --- /dev/null +++ b/certora/confs/setup/sanity_OperatorGrid.conf @@ -0,0 +1,96 @@ +{ + // Removed assert_autofinder_success - it fails + // "assert_autofinder_success": true, + "build_cache": true, + "files": [ + "contracts/0.8.25/vaults/OperatorGrid.sol", + "contracts/0.8.25/vaults/StakingVault.sol", + "contracts/0.8.25/vaults/VaultHub.sol", + "contracts/0.8.9/LidoLocator.sol", + "certora/mocks/ILidoMock.sol", + "certora/mocks/StorageExtension.sol" + ], + "global_timeout": "7200", + "hashing_length_bound": "243", + "link": [ + "OperatorGrid:LIDO_LOCATOR=LidoLocator", + "LidoLocator:vaultHub=VaultHub", + "VaultHub:LIDO_LOCATOR=LidoLocator", + "VaultHub:LIDO=ILidoMock" + ], + "loop_iter": "2", + "msg": "sanity_OperatorGrid", + "mutations": { + "gambit": [ + { + "filename": "contracts/0.8.25/vaults/OperatorGrid.sol", + "num_mutants": 5 + } + ] + }, + "optimistic_hashing": true, + "optimistic_loop": true, + "packages": [ + "@openzeppelin/contracts-v5.2=node_modules/@openzeppelin/contracts-v5.2", + "@aragon/apps-vault=node_modules/@aragon/apps-vault", + "@aragon/os=node_modules/@aragon/os", + "@aragon/apps-finance=node_modules/@aragon/apps-finance", + "@aragon/apps-agent=node_modules/@aragon/apps-agent", + "@openzeppelin/contracts=node_modules/@openzeppelin/contracts", + "@aragon/apps-lido=node_modules/@aragon/apps-lido", + "solhint-plugin-lido=node_modules/solhint-plugin-lido", + "@aragon/minime=node_modules/@aragon/minime", + "@openzeppelin/merkle-tree=node_modules/@openzeppelin/merkle-tree", + "solhint=node_modules/solhint", + "@aragon/id=node_modules/@aragon/id", + "@openzeppelin/contracts-v4.4=node_modules/@openzeppelin/contracts-v4.4", + "openzeppelin-solidity=node_modules/openzeppelin-solidity" + ], + "prover_args": [ + "-verifyCache ", + "-verifyTACDumps", + "-testMode", + "-checkRuleDigest", + "-callTraceHardFail on" + ], + "prover_version": "master", + // NOTE. The compilation parameters are from `hardhat.config.ts` + "compiler_map": { + "OperatorGrid": "solc8.25", + "StakingVault": "solc8.25", + "VaultHub": "solc8.25", + "LidoLocator": "solc8.9", + "ILidoMock": "solc8.25", + "StorageExtension": "solc8.25" + }, + "solc_evm_version_map": { + "OperatorGrid": "cancun", + "StakingVault": "cancun", + "VaultHub": "cancun", + "LidoLocator": "istanbul", + "ILidoMock": "cancun", + "StorageExtension": "cancun" + }, + "solc_via_ir_map": { + "OperatorGrid": true, + "StakingVault": true, + "VaultHub": true, + "LidoLocator": false, + "ILidoMock": true, + "StorageExtension": true + }, + "solc_optimize_map": { + "OperatorGrid": "200", + "StakingVault": "200", + "VaultHub": "200", + "LidoLocator": "200", + "ILidoMock": "200", + "StorageExtension": "200" + }, + "storage_extension_harnesses": [ + "OperatorGrid=StorageExtension", + "StakingVault=StorageExtension", + "VaultHub=StorageExtension" + ], + "verify": "OperatorGrid:certora/specs/setup/sanity_OperatorGrid.spec" +} diff --git a/certora/confs/setup/sanity_PredepositGuarantee.conf b/certora/confs/setup/sanity_PredepositGuarantee.conf new file mode 100644 index 0000000000..fcd5f58716 --- /dev/null +++ b/certora/confs/setup/sanity_PredepositGuarantee.conf @@ -0,0 +1,83 @@ +{ + // Removed assert_autofinder_success - it fails + // "assert_autofinder_success": true, + "build_cache": true, + "files": [ + "contracts/0.8.25/vaults/predeposit_guarantee/PredepositGuarantee.sol", + "contracts/0.8.25/vaults/StakingVault.sol", + "contracts/0.6.11/deposit_contract.sol:DepositContract", + "certora/mocks/PayableMock.sol", + "certora/mocks/StorageExtension.sol" + ], + "global_timeout": "7200", + "hashing_length_bound": "202", + "link": [ + "StakingVault:DEPOSIT_CONTRACT=DepositContract" + ], + "loop_iter": "2", + "msg": "sanity_PredepositGuarantee", + "mutations": { + "gambit": [ + { + "filename": "contracts/0.8.25/vaults/predeposit_guarantee/PredepositGuarantee.sol", + "num_mutants": 5 + } + ] + }, + "optimistic_fallback": true, + "optimistic_hashing": true, + "optimistic_loop": true, + "packages": [ + "@aragon/apps-agent=node_modules/@aragon/apps-agent", + "@aragon/apps-vault=node_modules/@aragon/apps-vault", + "openzeppelin-solidity=node_modules/openzeppelin-solidity", + "@aragon/os=node_modules/@aragon/os", + "@aragon/apps-lido=node_modules/@aragon/apps-lido", + "@openzeppelin/contracts-v4.4=node_modules/@openzeppelin/contracts-v4.4", + "@aragon/id=node_modules/@aragon/id", + "@openzeppelin/contracts-v5.2=node_modules/@openzeppelin/contracts-v5.2", + "solhint-plugin-lido=node_modules/solhint-plugin-lido", + "solhint=node_modules/solhint", + "@aragon/minime=node_modules/@aragon/minime", + "@aragon/apps-finance=node_modules/@aragon/apps-finance" + ], + "prover_args": [ + "-verifyCache ", + "-verifyTACDumps", + "-testMode", + "-checkRuleDigest", + "-callTraceHardFail on" + ], + "prover_version": "master", + // NOTE. The compilation parameters are from `hardhat.config.ts` + "compiler_map": { + "PredepositGuarantee": "solc8.25", + "StakingVault": "solc8.25", + "DepositContract": "solc6.11", + "PayableMock": "solc8.25", + "StorageExtension": "solc8.25" + }, + "solc_evm_version_map": { + "PredepositGuarantee": "cancun", + "StakingVault": "cancun", + "DepositContract": "istanbul", + "PayableMock": "cancun", + "StorageExtension": "cancun" + }, + "solc_via_ir_map": { + "PredepositGuarantee": true, + "StakingVault": true, + "DepositContract": false, + "PayableMock": true, + "StorageExtension": true + }, + "solc_optimize": "200", + "storage_extension_harnesses": [ + "PredepositGuarantee=StorageExtension", + "StakingVault=StorageExtension" + ], + "struct_link": [ + "PredepositGuarantee:stakingVault=StakingVault" + ], + "verify": "PredepositGuarantee:certora/specs/setup/sanity_PredepositGuarantee.spec" +} diff --git a/certora/confs/setup/sanity_StakingVault.conf b/certora/confs/setup/sanity_StakingVault.conf new file mode 100644 index 0000000000..90522c34c8 --- /dev/null +++ b/certora/confs/setup/sanity_StakingVault.conf @@ -0,0 +1,79 @@ +{ + // Removed assert_autofinder_success - it fails + // "assert_autofinder_success": true, + "build_cache": true, + "files": [ + "contracts/0.8.25/vaults/StakingVault.sol", + "contracts/0.6.11/deposit_contract.sol:DepositContract", + "certora/mocks/PayableMock.sol", + "certora/mocks/StorageExtension.sol", + "certora/mocks/WithdrawableRequestMock.sol" + ], + "global_timeout": "7200", + "link": [ + "StakingVault:DEPOSIT_CONTRACT=DepositContract" + ], + "loop_iter": "2", + "msg": "sanity_StakingVault", + "mutations": { + "gambit": [ + { + "filename": "contracts/0.8.25/vaults/StakingVault.sol", + "num_mutants": 5 + } + ] + }, + "optimistic_fallback": true, + "optimistic_loop": true, + "packages": [ + "@aragon/apps-agent=node_modules/@aragon/apps-agent", + "@aragon/apps-finance=node_modules/@aragon/apps-finance", + "@aragon/apps-lido=node_modules/@aragon/apps-lido", + "@aragon/apps-vault=node_modules/@aragon/apps-vault", + "@aragon/id=node_modules/@aragon/id", + "@aragon/minime=node_modules/@aragon/minime", + "@aragon/os=node_modules/@aragon/os", + "@openzeppelin/contracts=node_modules/@openzeppelin/contracts", + "@openzeppelin/contracts-v4.4=node_modules/@openzeppelin/contracts-v4.4", + "@openzeppelin/contracts-v5.2=node_modules/@openzeppelin/contracts-v5.2", + "@openzeppelin/merkle-tree=node_modules/@openzeppelin/merkle-tree", + "openzeppelin-solidity=node_modules/openzeppelin-solidity", + "solhint=node_modules/solhint", + "solhint-plugin-lido=node_modules/solhint-plugin-lido" + ], + "prover_args": [ + "-verifyCache ", + "-verifyTACDumps", + "-testMode", + "-checkRuleDigest", + "-callTraceHardFail on" + ], + "prover_version": "master", + // NOTE. The compilation parameters are from `hardhat.config.ts` + "compiler_map": { + "StakingVault": "solc8.25", + "DepositContract": "solc6.11", + "PayableMock": "solc8.25", + "StorageExtension": "solc8.25", + "WithdrawableRequestMock": "solc8.25" + }, + "solc_evm_version_map": { + "StakingVault": "cancun", + "DepositContract": "istanbul", + "PayableMock": "cancun", + "StorageExtension": "cancun", + "WithdrawableRequestMock": "cancun" + }, + "solc_via_ir_map": { + "StakingVault": true, + "DepositContract": false, + "PayableMock": true, + "StorageExtension": true, + "WithdrawableRequestMock": true + }, + "solc_optimize": "200", + "storage_extension_harnesses": [ + "StakingVault=StorageExtension" + ], + "verify": "StakingVault:certora/specs/setup/sanity_StakingVault.spec" +} diff --git a/certora/confs/setup/sanity_VaultFactory.conf b/certora/confs/setup/sanity_VaultFactory.conf new file mode 100644 index 0000000000..525a25bde8 --- /dev/null +++ b/certora/confs/setup/sanity_VaultFactory.conf @@ -0,0 +1,127 @@ +{ + "assert_autofinder_success": true, + "build_cache": true, + "dynamic_bound": "1", + "files": [ + "contracts/0.8.25/vaults/dashboard/Dashboard.sol", + "contracts/0.8.25/vaults/dashboard/NodeOperatorFee.sol", + "contracts/0.8.25/vaults/predeposit_guarantee/PredepositGuarantee.sol", + "contracts/0.8.25/vaults/LazyOracle.sol", + "contracts/0.8.25/vaults/OperatorGrid.sol", + "contracts/0.8.25/vaults/StakingVault.sol", + "contracts/0.8.25/vaults/VaultFactory.sol", + "contracts/0.8.25/vaults/VaultHub.sol", + "contracts/0.8.9/LidoLocator.sol", + "contracts/0.4.24/Lido.sol", + "certora/mocks/IHashConsensusMock.sol", + "certora/mocks/ILidoMock.sol", + "certora/mocks/StorageExtension.sol" + ], + "global_timeout": "7200", + "link": [ + "Dashboard:VAULT_HUB=VaultHub", + "Dashboard:STETH=Lido", + "LidoLocator:vaultHub=VaultHub", + "LidoLocator:operatorGrid=OperatorGrid", + "LidoLocator:lazyOracle=LazyOracle", + "LidoLocator:predepositGuarantee=PredepositGuarantee", + "VaultFactory:LIDO_LOCATOR=LidoLocator", + "VaultHub:LIDO_LOCATOR=LidoLocator", + "VaultHub:LIDO=ILidoMock", + "VaultHub:CONSENSUS_CONTRACT=IHashConsensusMock" + ], + "loop_iter": "2", + "msg": "sanity_VaultFactory", + "mutations": { + "gambit": [ + { + "filename": "contracts/0.8.25/vaults/VaultFactory.sol", + "num_mutants": 5 + } + ] + }, + "optimistic_loop": true, + "packages": [ + "@aragon/os=node_modules/@aragon/os", + "@openzeppelin/contracts-v4.4=node_modules/@openzeppelin/contracts-v4.4", + "@openzeppelin/contracts-v5.2=node_modules/@openzeppelin/contracts-v5.2", + "openzeppelin-solidity=node_modules/openzeppelin-solidity" + ], + "prover_args": [ + "-verifyCache ", + "-verifyTACDumps", + "-testMode", + "-checkRuleDigest", + "-callTraceHardFail on" + ], + "prover_version": "master", + // NOTE. The compilation parameters are from `hardhat.config.ts` + "compiler_map": { + "VaultFactory": "solc8.25", + "StakingVault": "solc8.25", + "NodeOperatorFee": "solc8.25", + "LazyOracle": "solc8.25", + "VaultHub": "solc8.25", + "Dashboard": "solc8.25", + "OperatorGrid": "solc8.25", + "PredepositGuarantee": "solc8.25", + "LidoLocator": "solc8.9", + "Lido": "solc4.24", + "IHashConsensusMock": "solc8.25", + "ILidoMock": "solc8.25", + "StorageExtension": "solc8.25" + }, + "solc_evm_version_map": { + "VaultFactory": "cancun", + "StakingVault": "cancun", + "NodeOperatorFee": "cancun", + "LazyOracle": "cancun", + "VaultHub": "cancun", + "Dashboard": "cancun", + "OperatorGrid": "cancun", + "PredepositGuarantee": "cancun", + "LidoLocator": "istanbul", + "Lido": "constantinople", + "IHashConsensusMock": "cancun", + "ILidoMock": "cancun", + "StorageExtension": "cancun" + }, + "solc_via_ir_map": { + "Dashboard": true, + "NodeOperatorFee": true, + "PredepositGuarantee": true, + "LazyOracle": true, + "OperatorGrid": true, + "StakingVault": true, + "VaultFactory": true, + "VaultHub": true, + "LidoLocator": true, + "Lido": false, + "IHashConsensusMock": true, + "ILidoMock": true, + "StorageExtension": true + }, + "solc_optimize_map": { + "VaultFactory": "200", + "StakingVault": "200", + "NodeOperatorFee": "200", + "LazyOracle": "200", + "VaultHub": "100", + "Dashboard": "200", + "OperatorGrid": "200", + "PredepositGuarantee": "200", + "LidoLocator": "200", + "Lido": "200", + "IHashConsensusMock": "200", + "ILidoMock": "200", + "StorageExtension": "200" + }, + "storage_extension_harnesses": [ + "PredepositGuarantee=StorageExtension", + "LazyOracle=StorageExtension", + "OperatorGrid=StorageExtension", + "StakingVault=StorageExtension", + "VaultHub=StorageExtension" + ], + "verify": "VaultFactory:certora/specs/setup/sanity_VaultFactory.spec" +} diff --git a/certora/confs/setup/sanity_VaultHub.conf b/certora/confs/setup/sanity_VaultHub.conf new file mode 100644 index 0000000000..ad05f2849b --- /dev/null +++ b/certora/confs/setup/sanity_VaultHub.conf @@ -0,0 +1,132 @@ +{ + // Removed assert_autofinder_success - it fails + // "assert_autofinder_success": true, + "build_cache": true, + "files": [ + "contracts/0.8.25/vaults/predeposit_guarantee/PredepositGuarantee.sol", + "contracts/0.8.25/vaults/LazyOracle.sol", + "contracts/0.8.25/vaults/OperatorGrid.sol", + "contracts/0.8.25/vaults/StakingVault.sol", + "contracts/0.8.25/vaults/VaultHub.sol", + "contracts/0.8.9/LidoLocator.sol", + "certora/mocks/IHashConsensusMock.sol", + "certora/mocks/ILidoMock.sol", + "certora/mocks/PayableMock.sol", + "certora/mocks/StorageExtension.sol", + "certora/mocks/WithdrawableRequestMock.sol" + ], + "global_timeout": "7200", + "hashing_length_bound": "98", + "link": [ + "LazyOracle:LIDO_LOCATOR=LidoLocator", + "LidoLocator:predepositGuarantee=PredepositGuarantee", + "LidoLocator:vaultHub=VaultHub", + "LidoLocator:operatorGrid=OperatorGrid", + "LidoLocator:lazyOracle=LazyOracle", + "OperatorGrid:LIDO_LOCATOR=LidoLocator", + "VaultHub:LIDO=ILidoMock", + "VaultHub:LIDO_LOCATOR=LidoLocator", + "VaultHub:CONSENSUS_CONTRACT=IHashConsensusMock" + ], + "loop_iter": "2", + "msg": "sanity_VaultHub", + "mutations": { + "gambit": [ + { + "filename": "contracts/0.8.25/vaults/VaultHub.sol", + "num_mutants": 5 + } + ] + }, + "optimistic_fallback": true, + "optimistic_hashing": true, + "optimistic_loop": true, + "packages": [ + "@aragon/minime=node_modules/@aragon/minime", + "@aragon/apps-agent=node_modules/@aragon/apps-agent", + "@aragon/id=node_modules/@aragon/id", + "@openzeppelin/contracts-v5.2=node_modules/@openzeppelin/contracts-v5.2", + "solhint=node_modules/solhint", + "openzeppelin-solidity=node_modules/openzeppelin-solidity", + "@aragon/os=node_modules/@aragon/os", + "@aragon/apps-finance=node_modules/@aragon/apps-finance", + "@openzeppelin/contracts=node_modules/@openzeppelin/contracts", + "@aragon/apps-lido=node_modules/@aragon/apps-lido", + "@openzeppelin/contracts-v4.4=node_modules/@openzeppelin/contracts-v4.4", + "solhint-plugin-lido=node_modules/solhint-plugin-lido", + "@aragon/apps-vault=node_modules/@aragon/apps-vault", + "@openzeppelin/merkle-tree=node_modules/@openzeppelin/merkle-tree" + ], + "prover_args": [ + "-verifyCache ", + "-verifyTACDumps", + "-testMode", + "-checkRuleDigest", + "-callTraceHardFail on" + ], + "prover_version": "master", + // NOTE. The compilation parameters are from `hardhat.config.ts` + "compiler_map": { + "PredepositGuarantee": "solc8.25", + "LazyOracle": "solc8.25", + "OperatorGrid": "solc8.25", + "StakingVault": "solc8.25", + "VaultHub": "solc8.25", + "LidoLocator": "solc8.9", + "IHashConsensusMock": "solc8.25", + "ILidoMock": "solc8.25", + "PayableMock": "solc8.25", + "StorageExtension": "solc8.25", + "WithdrawableRequestMock": "solc8.25" + }, + "solc_evm_version_map": { + "PredepositGuarantee": "cancun", + "LazyOracle": "cancun", + "OperatorGrid": "cancun", + "StakingVault": "cancun", + "VaultHub": "cancun", + "LidoLocator": "istanbul", + "IHashConsensusMock": "cancun", + "ILidoMock": "cancun", + "PayableMock": "cancun", + "StorageExtension": "cancun", + "WithdrawableRequestMock": "cancun" + }, + "solc_via_ir_map": { + "PredepositGuarantee": true, + "LazyOracle": true, + "OperatorGrid": true, + "StakingVault": true, + "VaultHub": true, + "LidoLocator": false, + "IHashConsensusMock": true, + "ILidoMock": true, + "PayableMock": true, + "StorageExtension": true, + "WithdrawableRequestMock": true + }, + "solc_optimize_map": { + "PredepositGuarantee": "200", + "LazyOracle": "200", + "OperatorGrid": "200", + "StakingVault": "200", + "VaultHub": "100", + "LidoLocator": "200", + "IHashConsensusMock": "200", + "ILidoMock": "200", + "PayableMock": "200", + "StorageExtension": "200", + "WithdrawableRequestMock": "200" + }, + "storage_extension_harnesses": [ + "PredepositGuarantee=StorageExtension", + "LazyOracle=StorageExtension", + "OperatorGrid=StorageExtension", + "StakingVault=StorageExtension", + "VaultHub=StorageExtension" + ], + "struct_link": [ + "PredepositGuarantee:stakingVault=StakingVault" + ], + "verify": "VaultHub:certora/specs/setup/sanity_VaultHub.spec" +} diff --git a/certora/confs/vaults/VaultHub-obligatedVaultIsConnected.conf b/certora/confs/vaults/VaultHub-obligatedVaultIsConnected.conf new file mode 100644 index 0000000000..721bc2149b --- /dev/null +++ b/certora/confs/vaults/VaultHub-obligatedVaultIsConnected.conf @@ -0,0 +1,125 @@ +{ + "files": [ + "contracts/0.8.25/vaults/predeposit_guarantee/PredepositGuarantee.sol", + "contracts/0.8.25/vaults/OperatorGrid.sol", + "contracts/0.8.25/vaults/StakingVault.sol", + "certora/harness/VaultHubHarness.sol", + "contracts/0.8.9/LidoLocator.sol", + "contracts/common/lib/BLS.sol:BLS12_381", + "certora/mocks/IHashConsensusMock.sol", + "certora/mocks/ILidoMock.sol", + "certora/mocks/PayableMock.sol", + "certora/mocks/StorageExtension.sol", + "certora/mocks/WithdrawableRequestMock.sol" + + ], + "server": "prover", + "prover_version": "master", + "link": [ + "LidoLocator:predepositGuarantee=PredepositGuarantee", + "LidoLocator:vaultHub=VaultHubHarness", + "LidoLocator:operatorGrid=OperatorGrid", + "OperatorGrid:LIDO_LOCATOR=LidoLocator", + "VaultHubHarness:LIDO=ILidoMock", + "VaultHubHarness:LIDO_LOCATOR=LidoLocator", + "VaultHubHarness:CONSENSUS_CONTRACT=IHashConsensusMock" + ], + "struct_link": [ + "PredepositGuarantee:stakingVault=StakingVault" + ], + "packages": [ + "@aragon/minime=node_modules/@aragon/minime", + "@aragon/apps-agent=node_modules/@aragon/apps-agent", + "@aragon/id=node_modules/@aragon/id", + "@openzeppelin/contracts-v5.2=node_modules/@openzeppelin/contracts-v5.2", + "solhint=node_modules/solhint", + "openzeppelin-solidity=node_modules/openzeppelin-solidity", + "@aragon/os=node_modules/@aragon/os", + "@aragon/apps-finance=node_modules/@aragon/apps-finance", + "@openzeppelin/contracts=node_modules/@openzeppelin/contracts", + "@aragon/apps-lido=node_modules/@aragon/apps-lido", + "@openzeppelin/contracts-v4.4=node_modules/@openzeppelin/contracts-v4.4", + "solhint-plugin-lido=node_modules/solhint-plugin-lido", + "@aragon/apps-vault=node_modules/@aragon/apps-vault", + "@openzeppelin/merkle-tree=node_modules/@openzeppelin/merkle-tree" + ], + "storage_extension_harnesses": [ + "PredepositGuarantee=StorageExtension", + "OperatorGrid=StorageExtension", + "StakingVault=StorageExtension", + "VaultHubHarness=StorageExtension" + ], + "global_timeout": "7200", + "build_cache": true, + "optimistic_fallback": true, + "optimistic_hashing": true, + "disallow_internal_function_calls": true, + "hashing_length_bound": "500", + "optimistic_loop": true, + "loop_iter": "1", + "compiler_map": { + "PredepositGuarantee": "solc8.25", + "OperatorGrid": "solc8.25", + "StakingVault": "solc8.25", + "VaultHubHarness": "solc8.25", + "LidoLocator": "solc8.9", + "IHashConsensusMock": "solc8.25", + "ILidoMock": "solc8.25", + "PayableMock": "solc8.25", + "StorageExtension": "solc8.25", + "WithdrawableRequestMock": "solc8.25", + "BLS12_381": "solc8.25" + }, + "solc_evm_version_map": { + "PredepositGuarantee": "cancun", + "OperatorGrid": "cancun", + "StakingVault": "cancun", + "VaultHubHarness": "cancun", + "LidoLocator": "istanbul", + "IHashConsensusMock": "cancun", + "ILidoMock": "cancun", + "PayableMock": "cancun", + "StorageExtension": "cancun", + "WithdrawableRequestMock": "cancun", + "BLS12_381": "cancun" + }, + "solc_via_ir_map": { + "PredepositGuarantee": true, + "OperatorGrid": true, + "StakingVault": true, + "VaultHubHarness": true, + "LidoLocator": false, + "IHashConsensusMock": true, + "ILidoMock": true, + "PayableMock": true, + "StorageExtension": true, + "WithdrawableRequestMock": true, + "BLS12_381": true + }, + "solc_optimize_map": { + "PredepositGuarantee": "200", + "OperatorGrid": "200", + "StakingVault": "200", + "VaultHubHarness": "100", + "LidoLocator": "200", + "IHashConsensusMock": "200", + "ILidoMock": "200", + "PayableMock": "200", + "StorageExtension": "200", + "WithdrawableRequestMock": "200", + "BLS12_381": "200" + }, + "prover_args": [ + "-mediumTimeout", "20", + "-depth", "15", + "-destructiveOptimizations", + "twostage", + "-s", + "[yices:def,z3:def{randomSeed=1},z3:def{randomSeed=2},z3:def{randomSeed=3},z3:def{randomSeed=4},z3:def{randomSeed=5},z3:def{randomSeed=6},z3:def{randomSeed=7},z3:def{randomSeed=8},z3:def{randomSeed=9},z3:def{randomSeed=10}]" + ], + "rule_sanity": "basic", + "verify": "VaultHubHarness:certora/specs/vaults/VaultHub.spec", + "rule": ["obligatedVaultIsConnected"], + "msg": "verify VaultHub - obligatedVaultIsConnected" +} + diff --git a/certora/confs/vaults/VaultHub.conf b/certora/confs/vaults/VaultHub.conf new file mode 100644 index 0000000000..874ddc64aa --- /dev/null +++ b/certora/confs/vaults/VaultHub.conf @@ -0,0 +1,125 @@ +{ + "files": [ + "contracts/0.8.25/vaults/predeposit_guarantee/PredepositGuarantee.sol", + "contracts/0.8.25/vaults/OperatorGrid.sol", + "contracts/0.8.25/vaults/StakingVault.sol", + "certora/harness/VaultHubHarness.sol", + "contracts/0.8.9/LidoLocator.sol", + "contracts/common/lib/BLS.sol:BLS12_381", + "certora/mocks/IHashConsensusMock.sol", + "certora/mocks/ILidoMock.sol", + "certora/mocks/PayableMock.sol", + "certora/mocks/StorageExtension.sol", + "certora/mocks/WithdrawableRequestMock.sol" + + ], + "server": "prover", + "prover_version": "master", + "link": [ + "LidoLocator:predepositGuarantee=PredepositGuarantee", + "LidoLocator:vaultHub=VaultHubHarness", + "LidoLocator:operatorGrid=OperatorGrid", + "OperatorGrid:LIDO_LOCATOR=LidoLocator", + "VaultHubHarness:LIDO=ILidoMock", + "VaultHubHarness:LIDO_LOCATOR=LidoLocator", + "VaultHubHarness:CONSENSUS_CONTRACT=IHashConsensusMock" + ], + "struct_link": [ + "PredepositGuarantee:stakingVault=StakingVault" + ], + "packages": [ + "@aragon/minime=node_modules/@aragon/minime", + "@aragon/apps-agent=node_modules/@aragon/apps-agent", + "@aragon/id=node_modules/@aragon/id", + "@openzeppelin/contracts-v5.2=node_modules/@openzeppelin/contracts-v5.2", + "solhint=node_modules/solhint", + "openzeppelin-solidity=node_modules/openzeppelin-solidity", + "@aragon/os=node_modules/@aragon/os", + "@aragon/apps-finance=node_modules/@aragon/apps-finance", + "@openzeppelin/contracts=node_modules/@openzeppelin/contracts", + "@aragon/apps-lido=node_modules/@aragon/apps-lido", + "@openzeppelin/contracts-v4.4=node_modules/@openzeppelin/contracts-v4.4", + "solhint-plugin-lido=node_modules/solhint-plugin-lido", + "@aragon/apps-vault=node_modules/@aragon/apps-vault", + "@openzeppelin/merkle-tree=node_modules/@openzeppelin/merkle-tree" + ], + "storage_extension_harnesses": [ + "PredepositGuarantee=StorageExtension", + "OperatorGrid=StorageExtension", + "StakingVault=StorageExtension", + "VaultHubHarness=StorageExtension" + ], + "global_timeout": "7200", + "build_cache": true, + "optimistic_fallback": true, + "optimistic_hashing": true, + "disallow_internal_function_calls": true, // speedup compilation + // Needs more than the default 98 for _collectAndCheckConfirmations which + // hashes the calldata to OperatorGrid.changeTier + "hashing_length_bound": "500", + "optimistic_loop": true, + "loop_iter": "2", + // NOTE. The compilation parameters are from `hardhat.config.ts` + "compiler_map": { + "PredepositGuarantee": "solc8.25", + "OperatorGrid": "solc8.25", + "StakingVault": "solc8.25", + "VaultHubHarness": "solc8.25", + "LidoLocator": "solc8.9", + "IHashConsensusMock": "solc8.25", + "ILidoMock": "solc8.25", + "PayableMock": "solc8.25", + "StorageExtension": "solc8.25", + "WithdrawableRequestMock": "solc8.25", + "BLS12_381": "solc8.25" + }, + "solc_evm_version_map": { + "PredepositGuarantee": "cancun", + "OperatorGrid": "cancun", + "StakingVault": "cancun", + "VaultHubHarness": "cancun", + "LidoLocator": "istanbul", + "IHashConsensusMock": "cancun", + "ILidoMock": "cancun", + "PayableMock": "cancun", + "StorageExtension": "cancun", + "WithdrawableRequestMock": "cancun", + "BLS12_381": "cancun" + }, + "solc_via_ir_map": { + "PredepositGuarantee": true, + "OperatorGrid": true, + "StakingVault": true, + "VaultHubHarness": true, + "LidoLocator": false, + "IHashConsensusMock": true, + "ILidoMock": true, + "PayableMock": true, + "StorageExtension": true, + "WithdrawableRequestMock": true, + "BLS12_381": true + }, + "solc_optimize_map": { + "PredepositGuarantee": "200", + "OperatorGrid": "200", + "StakingVault": "200", + "VaultHubHarness": "100", + "LidoLocator": "200", + "IHashConsensusMock": "200", + "ILidoMock": "200", + "PayableMock": "200", + "StorageExtension": "200", + "WithdrawableRequestMock": "200", + "BLS12_381": "200" + }, + "rule_sanity": "basic", + "prover_args": [ + "-splitParallel", + "true", + "-dontStopAtFirstSplitTimeout", + "true" + ], + "smt_timeout": 7200, + "verify": "VaultHubHarness:certora/specs/vaults/VaultHub.spec", + "msg": "verify VaultHub" +} \ No newline at end of file diff --git a/certora/confs/vaults/VaultHub_health.conf b/certora/confs/vaults/VaultHub_health.conf new file mode 100644 index 0000000000..a741b4fcf8 --- /dev/null +++ b/certora/confs/vaults/VaultHub_health.conf @@ -0,0 +1,16 @@ +{ + "override_base_config": "certora/confs/vaults/VaultHub.conf", + "disallow_internal_function_calls": false, + "build_cache": true, + "optimistic_fallback": true, + "optimistic_hashing": true, + // Needs more than the default 98 for _collectAndCheckConfirmations which + // hashes the calldata to OperatorGrid.changeTier + "hashing_length_bound": "500", + "optimistic_loop": true, + "loop_iter": "2", + "rule_sanity": "basic", + "verify": "VaultHubHarness:certora/specs/vaults/VaultHub_health.spec", + "smt_timeout": "7200", + "msg": "verify VaultHub health part 1" +} diff --git a/certora/confs/vaults/VaultHub_health_part1.conf b/certora/confs/vaults/VaultHub_health_part1.conf new file mode 100644 index 0000000000..a6d6c9ab46 --- /dev/null +++ b/certora/confs/vaults/VaultHub_health_part1.conf @@ -0,0 +1,38 @@ +{ + "override_base_config": "certora/confs/vaults/VaultHub.conf", + "method": [ + "requestValidatorExit(address,bytes)", + "getVaultRecordInOutDelta(address,uint48)", + "internalizeBadDebt(address,uint256)", + "setLiabilitySharesTarget(address,uint256)", + "transferVaultOwnership(address,address)", + "resume()", + "pauseFor(uint256)", + "decreaseInternalizedBadDebt(uint256)", + "pauseUntil(uint256)", + "resumeBeaconChainDeposits(address)", + "getVaultRecordBothDeltas(address)", + "initialize(address)", + "connectVault(address)", + "pauseBeaconChainDeposits(address)", + "grantRole(bytes32,address)", + "revokeRole(bytes32,address)", + "renounceRole(bytes32,address)", + "proveUnknownValidatorToPDG(address,(bytes32[],bytes,uint256,uint64,uint64,uint64))", + "collectERC20FromVault(address,address,address,uint256)", + "socializeBadDebt(address,address,uint256)" + ], + "disallow_internal_function_calls": false, + "build_cache": true, + "optimistic_fallback": true, + "optimistic_hashing": true, + // Needs more than the default 98 for _collectAndCheckConfirmations which + // hashes the calldata to OperatorGrid.changeTier + "hashing_length_bound": "500", + "optimistic_loop": true, + "loop_iter": "2", + "rule_sanity": "basic", + "verify": "VaultHubHarness:certora/specs/vaults/VaultHub_health.spec", + "smt_timeout": "7200", + "msg": "verify VaultHub health part 1" +} diff --git a/certora/confs/vaults/VaultHub_health_part2.conf b/certora/confs/vaults/VaultHub_health_part2.conf new file mode 100644 index 0000000000..cec7acae8f --- /dev/null +++ b/certora/confs/vaults/VaultHub_health_part2.conf @@ -0,0 +1,27 @@ +{ + "override_base_config": "certora/confs/vaults/VaultHub.conf", + "rule": "vaultIsHealtyhUntilReport", + "method": [ + "updateConnection(address,uint256,uint256,uint256,uint256,uint256,uint256)", + "transferAndBurnShares(address,uint256)", + "voluntaryDisconnect(address)", + "triggerValidatorWithdrawals(address,bytes,uint64[],address)" + ], + "disallow_internal_function_calls": false, + "build_cache": true, + "optimistic_fallback": true, + "optimistic_hashing": true, + // Needs more than the default 98 for _collectAndCheckConfirmations which + // hashes the calldata to OperatorGrid.changeTier + "hashing_length_bound": "500", + "optimistic_loop": true, + "loop_iter": "2", + "rule_sanity": "basic", + "verify": "VaultHubHarness:certora/specs/vaults/VaultHub_health.spec", + "smt_timeout": "7200", + "prover_args": [ + "-split", + "false" + ], + "msg": "verify VaultHub health part 2" +} \ No newline at end of file diff --git a/certora/confs/vaults/VaultHub_health_part3.conf b/certora/confs/vaults/VaultHub_health_part3.conf new file mode 100644 index 0000000000..6ac32412fc --- /dev/null +++ b/certora/confs/vaults/VaultHub_health_part3.conf @@ -0,0 +1,26 @@ +{ + "override_base_config": "certora/confs/vaults/VaultHub.conf", + "rule": "vaultIsHealtyhUntilReport", + "method": [ + "forceValidatorExit(address,bytes,address)", + "burnShares(address,uint256)" + ], + "build_cache": true, + "optimistic_fallback": true, + "optimistic_hashing": true, + "disallow_internal_function_calls": false, + // Needs more than the default 98 for _collectAndCheckConfirmations which + // hashes the calldata to OperatorGrid.changeTier + "hashing_length_bound": "500", + "optimistic_loop": true, + "loop_iter": "2", + "rule_sanity": "basic", + "verify": "VaultHubHarness:certora/specs/vaults/VaultHub_health.spec", + "prover_args": [ + "-splitParallel", + "true", + "-dontStopAtFirstSplitTimeout", + "true" + ], + "msg": "verify VaultHub health part 3" +} diff --git a/certora/confs/vaults/VaultHub_health_part4.conf b/certora/confs/vaults/VaultHub_health_part4.conf new file mode 100644 index 0000000000..5aaf02444b --- /dev/null +++ b/certora/confs/vaults/VaultHub_health_part4.conf @@ -0,0 +1,27 @@ +{ + "override_base_config": "certora/confs/vaults/VaultHub.conf", + "rule": "vaultIsHealtyhUntilReport", + "method": [ + "settleLidoFees(address)", + "withdraw(address,address,uint256)", + "mintShares(address,address,uint256)" + ], + "build_cache": true, + "optimistic_fallback": true, + "optimistic_hashing": true, + "disallow_internal_function_calls": false, + // Needs more than the default 98 for _collectAndCheckConfirmations which + // hashes the calldata to OperatorGrid.changeTier + "hashing_length_bound": "500", + "optimistic_loop": true, + "loop_iter": "2", + "rule_sanity": "basic", + "verify": "VaultHubHarness:certora/specs/vaults/VaultHub_health.spec", + "prover_args": [ + "-splitParallel", + "true", + "-dontStopAtFirstSplitTimeout", + "true" + ], + "msg": "verify VaultHub health part 4" +} diff --git a/certora/confs/vaults/VaultHub_health_part5.conf b/certora/confs/vaults/VaultHub_health_part5.conf new file mode 100644 index 0000000000..507525bdec --- /dev/null +++ b/certora/confs/vaults/VaultHub_health_part5.conf @@ -0,0 +1,21 @@ +{ + "override_base_config": "certora/confs/vaults/VaultHub.conf", + "rule": "vaultIsHealtyhUntilReport", + "method": [ + "rebalance(address,uint256)", + "burnShares(address,uint256)", + "disconnect(address)", + "forceRebalance(address)" + ], + "disallow_internal_function_calls": false, + "build_cache": true, + "optimistic_fallback": true, + "optimistic_hashing": true, + // Needs more than the default 98 for _collectAndCheckConfirmations which + // hashes the calldata to OperatorGrid.changeTier + "optimistic_loop": true, + "loop_iter": "2", + "rule_sanity": "basic", + "verify": "VaultHubHarness:certora/specs/vaults/VaultHub_health.spec", + "msg": "verify VaultHub part 5" +} diff --git a/certora/confs/vaults/VaultHub_health_part6.conf b/certora/confs/vaults/VaultHub_health_part6.conf new file mode 100644 index 0000000000..36306077c2 --- /dev/null +++ b/certora/confs/vaults/VaultHub_health_part6.conf @@ -0,0 +1,25 @@ +{ + "override_base_config": "certora/confs/vaults/VaultHub.conf", + "rule": "vaultIsHealtyhUntilReport", + "method": [ + "fund(address)" + ], + "build_cache": true, + "optimistic_fallback": true, + "optimistic_hashing": true, + "disallow_internal_function_calls": false, + // Needs more than the default 98 for _collectAndCheckConfirmations which + // hashes the calldata to OperatorGrid.changeTier + "hashing_length_bound": "500", + "optimistic_loop": true, + "loop_iter": "2", + "rule_sanity": "basic", + "verify": "VaultHubHarness:certora/specs/vaults/VaultHub_health.spec", + "prover_args": [ + "-splitParallel", + "true", + "-dontStopAtFirstSplitTimeout", + "true" + ], + "msg": "verify VaultHub health part 6" +} diff --git a/certora/confs/vaults/approximated-VaultHub.conf b/certora/confs/vaults/approximated-VaultHub.conf new file mode 100644 index 0000000000..608b59ae90 --- /dev/null +++ b/certora/confs/vaults/approximated-VaultHub.conf @@ -0,0 +1,76 @@ +{ + "files": [ + "contracts/0.8.25/vaults/predeposit_guarantee/PredepositGuarantee.sol", + "contracts/0.8.25/vaults/OperatorGrid.sol", + "contracts/0.8.25/vaults/StakingVault.sol", + "certora/harness/VaultHubHarness.sol", + "contracts/0.8.9/LidoLocator.sol", + "contracts/common/lib/BLS.sol:BLS12_381", + "certora/mocks/IHashConsensusMock.sol", + "certora/mocks/ILidoMock.sol", + "certora/mocks/PayableMock.sol", + "certora/mocks/StorageExtension.sol", + "certora/mocks/WithdrawableRequestMock.sol" + ], + "link": [ + "LidoLocator:predepositGuarantee=PredepositGuarantee", + "LidoLocator:vaultHub=VaultHubHarness", + "LidoLocator:operatorGrid=OperatorGrid", + "OperatorGrid:LIDO_LOCATOR=LidoLocator", + "VaultHubHarness:LIDO=ILidoMock", + "VaultHubHarness:LIDO_LOCATOR=LidoLocator", + "VaultHubHarness:CONSENSUS_CONTRACT=IHashConsensusMock" + ], + "struct_link": [ + "PredepositGuarantee:stakingVault=StakingVault" + ], + "packages": [ + "@aragon/minime=node_modules/@aragon/minime", + "@aragon/apps-agent=node_modules/@aragon/apps-agent", + "@aragon/id=node_modules/@aragon/id", + "@openzeppelin/contracts-v5.2=node_modules/@openzeppelin/contracts-v5.2", + "solhint=node_modules/solhint", + "openzeppelin-solidity=node_modules/openzeppelin-solidity", + "@aragon/os=node_modules/@aragon/os", + "@aragon/apps-finance=node_modules/@aragon/apps-finance", + "@openzeppelin/contracts=node_modules/@openzeppelin/contracts", + "@aragon/apps-lido=node_modules/@aragon/apps-lido", + "@openzeppelin/contracts-v4.4=node_modules/@openzeppelin/contracts-v4.4", + "solhint-plugin-lido=node_modules/solhint-plugin-lido", + "@aragon/apps-vault=node_modules/@aragon/apps-vault", + "@openzeppelin/merkle-tree=node_modules/@openzeppelin/merkle-tree" + ], + "storage_extension_harnesses": [ + "PredepositGuarantee=StorageExtension", + "OperatorGrid=StorageExtension", + "StakingVault=StorageExtension", + "VaultHubHarness=StorageExtension" + ], + "global_timeout": "7200", + "build_cache": true, + "optimistic_fallback": true, + "optimistic_hashing": true, + // Needs more than the default 98 for _collectAndCheckConfirmations which + // hashes the calldata to OperatorGrid.changeTier + "hashing_length_bound": "500", + "optimistic_loop": true, + "loop_iter": "2", + "compiler_map": { + "PredepositGuarantee": "solc8.25", + "OperatorGrid": "solc8.25", + "StakingVault": "solc8.25", + "VaultHubHarness": "solc8.25", + "LidoLocator": "solc8.9", + "IHashConsensusMock": "solc8.25", + "ILidoMock": "solc8.25", + "PayableMock": "solc8.25", + "StorageExtension": "solc8.25", + "WithdrawableRequestMock": "solc8.25", + "BLS12_381": "solc8.25" + }, + "solc_optimize": "1000", + "solc_via_ir": true, + "rule_sanity": "basic", + "verify": "VaultHubHarness:certora/specs/vaults/approximated-VaultHub.spec", + "msg": "verify VaultHub approximating shares to ETH conversions" +} diff --git a/certora/confs/vaults/immutable-ratio.conf b/certora/confs/vaults/immutable-ratio.conf new file mode 100644 index 0000000000..5a7e9b9556 --- /dev/null +++ b/certora/confs/vaults/immutable-ratio.conf @@ -0,0 +1,127 @@ +{ + "build_cache": true, + "compiler_map": { + "BLS12_381": "solc8.25", + "IHashConsensusMock": "solc8.25", + "ILidoMock": "solc8.25", + "LidoLocator": "solc8.9", + "OperatorGrid": "solc8.25", + "PayableMock": "solc8.25", + "PredepositGuarantee": "solc8.25", + "StakingVault": "solc8.25", + "StorageExtension": "solc8.25", + "VaultHubHarness": "solc8.25", + "WithdrawableRequestMock": "solc8.25" + }, + "disallow_internal_function_calls": true, // speedup compilation + "files": [ + "contracts/0.8.25/vaults/predeposit_guarantee/PredepositGuarantee.sol", + "contracts/0.8.25/vaults/OperatorGrid.sol", + "contracts/0.8.25/vaults/StakingVault.sol", + "certora/harness/VaultHubHarness.sol", + "contracts/0.8.9/LidoLocator.sol", + "contracts/common/lib/BLS.sol:BLS12_381", + "certora/mocks/IHashConsensusMock.sol", + "certora/mocks/ILidoMock.sol", + "certora/mocks/PayableMock.sol", + "certora/mocks/StorageExtension.sol", + "certora/mocks/WithdrawableRequestMock.sol" + ], + "global_timeout": 7200, + "hashing_length_bound": 500, + "link": [ + "LidoLocator:predepositGuarantee=PredepositGuarantee", + "LidoLocator:vaultHub=VaultHubHarness", + "LidoLocator:operatorGrid=OperatorGrid", + "OperatorGrid:LIDO_LOCATOR=LidoLocator", + "VaultHubHarness:LIDO=ILidoMock", + "VaultHubHarness:LIDO_LOCATOR=LidoLocator", + "VaultHubHarness:CONSENSUS_CONTRACT=IHashConsensusMock" + ], + "loop_iter": 2, + "method": [ + "forceRebalance(address)" + ], + "msg": "Immutable ratios", + "optimistic_fallback": true, + "optimistic_hashing": true, + "optimistic_loop": true, + "packages": [ + "@aragon/minime=node_modules/@aragon/minime", + "@aragon/apps-agent=node_modules/@aragon/apps-agent", + "@aragon/id=node_modules/@aragon/id", + "@openzeppelin/contracts-v5.2=node_modules/@openzeppelin/contracts-v5.2", + "solhint=node_modules/solhint", + "openzeppelin-solidity=node_modules/openzeppelin-solidity", + "@aragon/os=node_modules/@aragon/os", + "@aragon/apps-finance=node_modules/@aragon/apps-finance", + "@openzeppelin/contracts=node_modules/@openzeppelin/contracts", + "@aragon/apps-lido=node_modules/@aragon/apps-lido", + "@openzeppelin/contracts-v4.4=node_modules/@openzeppelin/contracts-v4.4", + "solhint-plugin-lido=node_modules/solhint-plugin-lido", + "@aragon/apps-vault=node_modules/@aragon/apps-vault", + "@openzeppelin/merkle-tree=node_modules/@openzeppelin/merkle-tree" + ], + "parametric_contracts": [ + "VaultHubHarness" + ], + "prover_args": [ + "-split", + "false" + ], + "rule": [ + "vaultLockedCoversLiabilityAndReserveImmutableRatio" + ], + "rule_sanity": "basic", + "server": "prover", + "smt_timeout": 7200, + "solc_evm_version_map": { + "BLS12_381": "cancun", + "IHashConsensusMock": "cancun", + "ILidoMock": "cancun", + "LidoLocator": "istanbul", + "OperatorGrid": "cancun", + "PayableMock": "cancun", + "PredepositGuarantee": "cancun", + "StakingVault": "cancun", + "StorageExtension": "cancun", + "VaultHubHarness": "cancun", + "WithdrawableRequestMock": "cancun" + }, + "solc_optimize_map": { + "BLS12_381": 200, + "IHashConsensusMock": 200, + "ILidoMock": 200, + "LidoLocator": 200, + "OperatorGrid": 200, + "PayableMock": 200, + "PredepositGuarantee": 200, + "StakingVault": 200, + "StorageExtension": 200, + "VaultHubHarness": 100, + "WithdrawableRequestMock": 200 + }, + "solc_via_ir_map": { + "BLS12_381": true, + "IHashConsensusMock": true, + "ILidoMock": true, + "LidoLocator": false, + "OperatorGrid": true, + "PayableMock": true, + "PredepositGuarantee": true, + "StakingVault": true, + "StorageExtension": true, + "VaultHubHarness": true, + "WithdrawableRequestMock": true + }, + "storage_extension_harnesses": [ + "PredepositGuarantee=StorageExtension", + "OperatorGrid=StorageExtension", + "StakingVault=StorageExtension", + "VaultHubHarness=StorageExtension" + ], + "struct_link": [ + "PredepositGuarantee:stakingVault=StakingVault" + ], + "verify": "VaultHubHarness:certora/specs/vaults/immutable-ratio.spec" +} diff --git a/certora/confs/vaults/lazy-oracle.conf b/certora/confs/vaults/lazy-oracle.conf new file mode 100644 index 0000000000..1b5ed0b9bb --- /dev/null +++ b/certora/confs/vaults/lazy-oracle.conf @@ -0,0 +1,134 @@ +{ + "build_cache": true, + "compiler_map": { + "BLS12_381": "solc8.25", + "IHashConsensusMock": "solc8.25", + "ILidoMock": "solc8.25", + "LazyOracleHarness": "solc8.25", + "LidoLocator": "solc8.9", + "MerkleProof": "solc8.25", + "OperatorGrid": "solc8.25", + "PayableMock": "solc8.25", + "PredepositGuarantee": "solc8.25", + "StakingVault": "solc8.25", + "StorageExtension": "solc8.25", + "StorageExtensionLazyOracle": "solc8.25", + "StorageExtensionOperatorGrid": "solc8.25", + "StorageExtensionStakingVault": "solc8.25", + "StorageExtensionVaultHub": "solc8.25", + "VaultHubHarness": "solc8.25", + "WithdrawableRequestMock": "solc8.25" + }, + "disallow_internal_function_calls": true, // speedup compilation + "files": [ + "certora/harness/LazyOracleHarness.sol", + "contracts/0.8.25/vaults/OperatorGrid.sol", + "contracts/0.8.25/vaults/StakingVault.sol", + "certora/harness/VaultHubHarness.sol", + "contracts/0.8.9/LidoLocator.sol", + "certora/mocks/IHashConsensusMock.sol", + "certora/mocks/ILidoMock.sol", + "certora/mocks/StorageExtension.sol", + "node_modules/@openzeppelin/contracts-v5.2/utils/cryptography/MerkleProof.sol" + ], + "global_timeout": 7200, + "hashing_length_bound": 500, + "link": [ + "LidoLocator:vaultHub=VaultHubHarness", + "LidoLocator:operatorGrid=OperatorGrid", + "LidoLocator:lazyOracle=LazyOracleHarness", + "OperatorGrid:LIDO_LOCATOR=LidoLocator", + "VaultHubHarness:LIDO=ILidoMock", + "VaultHubHarness:LIDO_LOCATOR=LidoLocator", + "VaultHubHarness:CONSENSUS_CONTRACT=IHashConsensusMock", + "LazyOracleHarness:LIDO_LOCATOR=LidoLocator" + ], + "loop_iter": 2, + "msg": "verify LazyOracleHarness", + "optimistic_fallback": true, + "optimistic_hashing": true, + "optimistic_loop": true, + "packages": [ + "@aragon/minime=node_modules/@aragon/minime", + "@aragon/apps-agent=node_modules/@aragon/apps-agent", + "@aragon/id=node_modules/@aragon/id", + "@openzeppelin/contracts-v5.2=node_modules/@openzeppelin/contracts-v5.2", + "solhint=node_modules/solhint", + "openzeppelin-solidity=node_modules/openzeppelin-solidity", + "@aragon/os=node_modules/@aragon/os", + "@aragon/apps-finance=node_modules/@aragon/apps-finance", + "@openzeppelin/contracts=node_modules/@openzeppelin/contracts", + "@aragon/apps-lido=node_modules/@aragon/apps-lido", + "@openzeppelin/contracts-v4.4=node_modules/@openzeppelin/contracts-v4.4", + "solhint-plugin-lido=node_modules/solhint-plugin-lido", + "@aragon/apps-vault=node_modules/@aragon/apps-vault", + "@openzeppelin/merkle-tree=node_modules/@openzeppelin/merkle-tree" + ], + "prover_version": "master", + "rule_sanity": "basic", + "server": "prover", + "solc_evm_version_map": { + "BLS12_381": "cancun", + "IHashConsensusMock": "cancun", + "ILidoMock": "cancun", + "LazyOracleHarness": "cancun", + "LidoLocator": "istanbul", + "MerkleProof": "cancun", + "OperatorGrid": "cancun", + "PayableMock": "cancun", + "PredepositGuarantee": "cancun", + "StakingVault": "cancun", + "StorageExtension": "cancun", + "StorageExtensionLazyOracle": "cancun", + "StorageExtensionOperatorGrid": "cancun", + "StorageExtensionStakingVault": "cancun", + "StorageExtensionVaultHub": "cancun", + "VaultHubHarness": "cancun", + "WithdrawableRequestMock": "cancun" + }, + "solc_optimize_map": { + "BLS12_381": 200, + "IHashConsensusMock": 200, + "ILidoMock": 200, + "LazyOracleHarness": 200, + "LidoLocator": 200, + "MerkleProof": 200, + "OperatorGrid": 200, + "PayableMock": 200, + "PredepositGuarantee": 200, + "StakingVault": 200, + "StorageExtension": 200, + "StorageExtensionLazyOracle": 200, + "StorageExtensionOperatorGrid": 200, + "StorageExtensionStakingVault": 200, + "StorageExtensionVaultHub": 200, + "VaultHubHarness": 100, + "WithdrawableRequestMock": 200 + }, + "solc_via_ir_map": { + "BLS12_381": true, + "IHashConsensusMock": true, + "ILidoMock": true, + "LazyOracleHarness": true, + "LidoLocator": false, + "MerkleProof": true, + "OperatorGrid": true, + "PayableMock": true, + "PredepositGuarantee": true, + "StakingVault": true, + "StorageExtension": true, + "StorageExtensionLazyOracle": true, + "StorageExtensionOperatorGrid": true, + "StorageExtensionStakingVault": true, + "StorageExtensionVaultHub": true, + "VaultHubHarness": true, + "WithdrawableRequestMock": true + }, + "storage_extension_harnesses": [ + "OperatorGrid=StorageExtension", + "StakingVault=StorageExtension", + "VaultHubHarness=StorageExtension", + "LazyOracleHarness=StorageExtension" + ], + "verify": "LazyOracleHarness:certora/specs/vaults/lazy-oracle.spec" +} diff --git a/certora/confs/vaults/predeposit.conf b/certora/confs/vaults/predeposit.conf new file mode 100644 index 0000000000..e8971733eb --- /dev/null +++ b/certora/confs/vaults/predeposit.conf @@ -0,0 +1,69 @@ +{ + "files": [ + "contracts/0.8.25/vaults/predeposit_guarantee/PredepositGuarantee.sol", + "contracts/0.8.25/vaults/StakingVault.sol", + "contracts/common/lib/BLS.sol:BLS12_381", + "certora/mocks/StorageExtensionPredepositGuarantee.sol", + "certora/mocks/StorageExtensionStakingVault.sol" + ], + "compiler_map": { + "PredepositGuarantee": "solc8.25", + "OperatorGrid": "solc8.25", + "StakingVault": "solc8.25", + "VaultHubHarness": "solc8.25", + "LidoLocator": "solc8.9", + "IHashConsensusMock": "solc8.25", + "ILidoMock": "solc8.25", + "PayableMock": "solc8.25", + "StorageExtension": "solc8.25", + "WithdrawableRequestMock": "solc8.25", + "BLS12_381": "solc8.25", + "StorageExtensionPredepositGuarantee": "solc8.25", + "StorageExtensionStakingVault": "solc8.25" + }, + "disallow_internal_function_calls": true, // speedup compilation + "server": "prover", + "prover_version": "abakst/cert-9655", // NOTE: Update to stable version when available + "struct_link": [ + "PredepositGuarantee:stakingVault=StakingVault" + ], + "packages": [ + "@aragon/minime=node_modules/@aragon/minime", + "@aragon/apps-agent=node_modules/@aragon/apps-agent", + "@aragon/id=node_modules/@aragon/id", + "@openzeppelin/contracts-v5.2=node_modules/@openzeppelin/contracts-v5.2", + "solhint=node_modules/solhint", + "openzeppelin-solidity=node_modules/openzeppelin-solidity", + "@aragon/os=node_modules/@aragon/os", + "@aragon/apps-finance=node_modules/@aragon/apps-finance", + "@openzeppelin/contracts=node_modules/@openzeppelin/contracts", + "@aragon/apps-lido=node_modules/@aragon/apps-lido", + "@openzeppelin/contracts-v4.4=node_modules/@openzeppelin/contracts-v4.4", + "solhint-plugin-lido=node_modules/solhint-plugin-lido", + "@aragon/apps-vault=node_modules/@aragon/apps-vault", + "@openzeppelin/merkle-tree=node_modules/@openzeppelin/merkle-tree" + ], + "storage_extension_harnesses": [ + "PredepositGuarantee=StorageExtensionPredepositGuarantee", + "StakingVault=StorageExtensionStakingVault" + ], + "parametric_contracts": [ + "PredepositGuarantee" + ], + "global_timeout": "7200", + "build_cache": true, + "optimistic_fallback": true, + "optimistic_hashing": true, + // Using `hashing_length_bound` of 32 since otherwise we get spurious counter examples, + // see https://certora.atlassian.net/browse/CERT-9553. + // Note that the length of the validator public key is 48, see https://t.me/c/1828369766/1361 + "hashing_length_bound": "32", + "optimistic_loop": true, + "loop_iter": "2", + "solc_optimize": "200", + "solc_via_ir": true, + "rule_sanity": "basic", + "verify": "PredepositGuarantee:certora/specs/vaults/predeposit.spec", + "msg": "verify PredepositGuarantee" +} + diff --git a/certora/confs/vaults/shortfall.conf b/certora/confs/vaults/shortfall.conf new file mode 100644 index 0000000000..fc2509e48f --- /dev/null +++ b/certora/confs/vaults/shortfall.conf @@ -0,0 +1,15 @@ +{ + "override_base_config": "certora/confs/vaults/VaultHub.conf", + "build_cache": true, + "optimistic_fallback": true, + "optimistic_hashing": true, + "optimistic_loop": true, + "loop_iter": "1", + "rule_sanity": "basic", + "disallow_internal_function_calls": true, + "verify": "VaultHubHarness:certora/specs/vaults/shortfall.spec", + "msg": "verify VaultHub shortfall integrity", + "nondet_difficult_funcs": true, + "smt_timeout": "600", + "prover_args": ["-mediumTimeout 30", "-depth 15", "-smt_nonLinearArithmetic true"] +} diff --git a/certora/harness/AccountingHarness.sol b/certora/harness/AccountingHarness.sol new file mode 100644 index 0000000000..78c46556a0 --- /dev/null +++ b/certora/harness/AccountingHarness.sol @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2023 Lido +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.9; + +import { Accounting } from "contracts/0.8.9/Accounting.sol"; +import { ILidoLocator } from "contracts/common/interfaces/ILidoLocator.sol"; +import { ILido } from "contracts/common/interfaces/ILido.sol"; +import { ReportValues } from "contracts/common/interfaces/ReportValues.sol"; + +contract AccountingHarness is Accounting { + constructor( + ILidoLocator _lidoLocator, + ILido _lido + ) Accounting(_lidoLocator, _lido) {} + + function treasury() external returns (address) { + return LIDO_LOCATOR.treasury(); + } + + function calculateTotalProtocolFeeShares( + ReportValues calldata _report, + CalculatedValues memory _update, + uint256 _internalSharesBeforeFees, + uint256 _totalFee, + uint256 _feePrecisionPoints + ) external pure returns (uint256 sharesToMintAsFees) { + return _calculateTotalProtocolFeeShares( + _report, _update, _internalSharesBeforeFees, _totalFee, _feePrecisionPoints + ); + } +} diff --git a/certora/harness/BurnerHarness.sol b/certora/harness/BurnerHarness.sol new file mode 100644 index 0000000000..5deaec1930 --- /dev/null +++ b/certora/harness/BurnerHarness.sol @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2023 Lido +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.9; + +import { Burner } from "contracts/0.8.9/Burner.sol"; + +contract BurnerHarness is Burner { + constructor(address _locator, address _stETH) Burner(_locator, _stETH) { + } + + + function getExcessStETHShares() external view returns (uint256) { + return _getExcessStETHShares(); + } +} diff --git a/certora/harness/LazyOracleHarness.sol b/certora/harness/LazyOracleHarness.sol new file mode 100644 index 0000000000..9ecdc1f882 --- /dev/null +++ b/certora/harness/LazyOracleHarness.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.25; + +import { LazyOracle } from "contracts/0.8.25/vaults/LazyOracle.sol"; +/* +import { VaultHub } from "contracts/0.8.25/vaults/VaultHub.sol"; +import { ILidoLocator } from "contracts/common/interfaces/ILidoLocator.sol"; +import { ILido } from "contracts/common/interfaces/ILido.sol"; +import { IHashConsensus } from "contracts/common/interfaces/IHashConsensus.sol"; +*/ + +/// @notice Harness for LazyOracle contract, exposing some functionality +contract LazyOracleHarness is LazyOracle { + constructor(address _lidoLocator) LazyOracle(_lidoLocator) { + } + + function handleSanityChecks( + address _vault, + uint256 _totalValue, + uint48 _reportRefSlot, + uint256 _reportTimestamp, + uint256 _cumulativeLidoFees, + uint256 _liabilityShares, + uint256 _maxLiabilityShares + ) external returns (uint256, int256) { + return _handleSanityChecks( + _vault, + _totalValue, + _reportRefSlot, + _reportTimestamp, + _cumulativeLidoFees, + _liabilityShares, + _maxLiabilityShares + ); + } +} diff --git a/certora/harness/LidoHarness.sol b/certora/harness/LidoHarness.sol new file mode 100644 index 0000000000..2b1b1802d6 --- /dev/null +++ b/certora/harness/LidoHarness.sol @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: 2023 Lido +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.4.24; + +import "contracts/0.4.24/Lido.sol"; +import {StakeLimitUnstructuredStorage} from "contracts/0.4.24/lib/StakeLimitUtils.sol"; + +contract LidoHarness is Lido { + using StakeLimitUnstructuredStorage for bytes32; + + function stakingModuleMaxDepositsCount(uint256 _stakingModuleId, uint256 _maxValue) public view returns (uint256) { + IStakingRouter stakingRouter = IStakingRouter(_getLidoLocator().stakingRouter()); + return stakingRouter.getStakingModuleMaxDepositsCount(_stakingModuleId, _maxValue); + } + + function LidoEthBalance() public view returns(uint256) { + return address(this).balance; + } + + function getEthBalance(address account) public view returns(uint256) { + return account.balance; + } + + function getInternalEther() external view returns (uint256) { + return _getInternalEther(); + } + + function getShareRateNumerator() external view returns (uint256) { + return _getShareRateNumerator(); + } + + function getShareRateDenominator() external view returns (uint256) { + return _getShareRateDenominator(); + } + + function getPrevStakeLimit() external view returns (uint96) { + StakeLimitState.Data memory stakeLimitData = STAKING_STATE_POSITION.getStorageStakeLimitStruct(); + return stakeLimitData.prevStakeLimit; + } + + function getPrevStakeBlockNumber() external view returns (uint32) { + StakeLimitState.Data memory stakeLimitData = STAKING_STATE_POSITION.getStorageStakeLimitStruct(); + return stakeLimitData.prevStakeBlockNumber; + } + + function getMaxStakeLimit() external view returns (uint96) { + StakeLimitState.Data memory stakeLimitData = STAKING_STATE_POSITION.getStorageStakeLimitStruct(); + return stakeLimitData.maxStakeLimit; + } + + function getMaxStakeLimitGrowthBlocks() external view returns (uint32) { + StakeLimitState.Data memory stakeLimitData = STAKING_STATE_POSITION.getStorageStakeLimitStruct(); + return stakeLimitData.maxStakeLimitGrowthBlocks; + } + + function getDepositedValidators() external view returns (uint256) { + return _getDepositedValidators(); + } + + function getBalanceAndClValidators() external view returns (uint256, uint256) { + return _getClBalanceAndClValidators(); + } +} diff --git a/certora/harness/NativeTransferFuncs.sol b/certora/harness/NativeTransferFuncs.sol new file mode 100644 index 0000000000..14536a11c7 --- /dev/null +++ b/certora/harness/NativeTransferFuncs.sol @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2023 Lido +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.9; + +interface ILido { + /** + * @notice A payable function supposed to be called only by WithdrawalVault contract + * @dev We need a dedicated function because funds received by the default payable function + * are treated as a user deposit + */ + function receiveWithdrawals() external payable; + + /** + * @notice A payable function supposed to be called only by LidoExecLayerRewardsVault contract + * @dev We need a dedicated function because funds received by the default payable function + * are treated as a user deposit + */ + function receiveELRewards() external payable; +} + +contract NativeTransferFuncs { + + ILido public LIDO; + + function withdrawRewards(uint256 amount) external returns (uint256) { + LIDO.receiveELRewards{value: amount}(); + return amount; + } + + function withdrawWithdrawals(uint256 amount) public { + LIDO.receiveWithdrawals{value: amount}(); + } + + function finalize(uint256 _lastIdToFinalize, uint256 _maxShareRate) external payable { + } +} diff --git a/certora/harness/VaultHubHarness.sol b/certora/harness/VaultHubHarness.sol new file mode 100644 index 0000000000..1825d61746 --- /dev/null +++ b/certora/harness/VaultHubHarness.sol @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.25; + +import { VaultHub } from "contracts/0.8.25/vaults/VaultHub.sol"; +import { ILidoLocator } from "contracts/common/interfaces/ILidoLocator.sol"; +import { ILido } from "contracts/common/interfaces/ILido.sol"; +import { IHashConsensus } from "contracts/common/interfaces/IHashConsensus.sol"; +import { DoubleRefSlotCache, DOUBLE_CACHE_LENGTH } from "contracts/0.8.25/vaults/lib/RefSlotCache.sol"; + +/// @notice Harness for VaultHub contract, exposing some data. +contract VaultHubHarness is VaultHub { + using DoubleRefSlotCache for DoubleRefSlotCache.Int104WithCache[DOUBLE_CACHE_LENGTH]; + + constructor( + ILidoLocator _locator, + ILido _lido, + IHashConsensus _consensusContract, + uint256 _maxRelativeShareLimitBP + ) VaultHub(_locator, _lido, _consensusContract, _maxRelativeShareLimitBP) { + } + + function getVaultRecordDeltaValue(address _vault) external view returns (int104) { + VaultRecord storage record = _vaultRecord(_vault); + return record.inOutDelta.currentValue(); + } + + function getVaultRecordInOutDelta(address _vault, uint48 _refSlot) external returns (int104) { + VaultRecord storage record = _vaultRecord(_vault); + DoubleRefSlotCache.Int104WithCache[DOUBLE_CACHE_LENGTH] memory inOutDelta = record.inOutDelta; + return inOutDelta.getValueForRefSlot(_refSlot); + } + + /// @dev This relies on DOUBLE_CACHE_LENGTH being 2! + function getVaultRecordBothDeltas(address _vault) external returns (int104, int104) { + VaultRecord storage record = _vaultRecord(_vault); + DoubleRefSlotCache.Int104WithCache[DOUBLE_CACHE_LENGTH] memory inOutDelta = record.inOutDelta; + return (inOutDelta[0].value, inOutDelta[1].value); + } + + function getVaultReportDelta(address _vault) external view returns (int104) { + VaultRecord storage record = _vaultRecord(_vault); + return record.report.inOutDelta; + } + + function getVaultReportTotal(address _vault) external view returns (uint104) { + VaultRecord storage record = _vaultRecord(_vault); + return record.report.totalValue; + } + + function vaultsArrayLength() external view returns (uint256) { + return _storage().vaults.length; + } + + function vaultArrayAtIndex(uint256 _index) external view returns (address) { + return _storage().vaults[_index]; + } + + function getInitializedVersion() external view returns (uint64) { + return _getInitializedVersion(); + } + + function reserveRatioBP(address _vault) external view returns (uint16) { + VaultConnection storage connection = _vaultConnection(_vault); + return connection.reserveRatioBP; + } + + function maxLiabilityShares(address _vault) external view returns (uint96) { + VaultRecord storage record = _vaultRecord(_vault); + return record.maxLiabilityShares; + } + + function minimalReserve(address _vault) external view returns (uint128) { + VaultRecord storage record = _vaultRecord(_vault); + return record.minimalReserve; + } + + function forcedRebalanceThresholdBP(address _vault) external view returns (uint16) { + VaultConnection storage connection = _vaultConnection(_vault); + return connection.forcedRebalanceThresholdBP; + } + + function unsettledLidoFees(address _vault) external view returns (uint256) { + VaultRecord storage record = _vaultRecord(_vault); + return _unsettledLidoFeesValue(record); + } + + function obligationsShares(address _vault) external view returns (uint256) { + VaultRecord storage record = _vaultRecord(_vault); + return _obligationsShares(_vaultConnection(_vault), record); + } + + function redemptionShares(address _vault) external view returns (uint128) { + VaultRecord storage record = _vaultRecord(_vault); + return record.redemptionShares; + } + + + function withdrawableValueFeesIncluded(address _vault) external view returns (uint256) { + VaultRecord storage record = _vaultRecord(_vault); + VaultConnection storage connection = _vaultConnection(_vault); + return _withdrawableValueFeesIncluded(_vault, connection, record); + } + + + function vaultData(address _vault) external view returns (uint256 reserveRatioBP_, uint256 thresholdBP_, uint256 totalValue_, uint256 liabilityShares_) { + VaultConnection storage connection = _vaultConnection(_vault); + VaultRecord storage record = _vaultRecord(_vault); + + reserveRatioBP_ = connection.reserveRatioBP; + thresholdBP_ = connection.forcedRebalanceThresholdBP; + liabilityShares_ = record.liabilityShares; + totalValue_ = _totalValue(record); + } + + +} diff --git a/certora/mocks/ERC721RecipientMock.sol b/certora/mocks/ERC721RecipientMock.sol new file mode 100644 index 0000000000..713c323125 --- /dev/null +++ b/certora/mocks/ERC721RecipientMock.sol @@ -0,0 +1,22 @@ +import {IERC721TokenReceiver} from "foundry/lib/forge-std/src/interfaces/IERC721.sol"; + +contract ERC721RecipientMock is IERC721TokenReceiver { + address public operator; + address public from; + uint256 public id; + bytes public data; + + function onERC721Received(address _operator, address _from, uint256 _id, bytes calldata _data) + public + virtual + override + returns (bytes4) + { + operator = _operator; + from = _from; + id = _id; + data = _data; + + return IERC721TokenReceiver.onERC721Received.selector; + } +} diff --git a/certora/mocks/IDepositContractMock.sol b/certora/mocks/IDepositContractMock.sol new file mode 100644 index 0000000000..f78f95f560 --- /dev/null +++ b/certora/mocks/IDepositContractMock.sol @@ -0,0 +1,12 @@ +import { IDepositContract } from "contracts/common/interfaces/IDepositContract.sol"; + +contract IDepositContractMock is IDepositContract { + bytes32 public override get_deposit_root; + + function deposit( + bytes calldata pubkey, + bytes calldata withdrawal_credentials, + bytes calldata signature, + bytes32 deposit_data_root + ) external payable override {} +} diff --git a/certora/mocks/IHashConsensusMock.sol b/certora/mocks/IHashConsensusMock.sol new file mode 100644 index 0000000000..bc5ecbba1a --- /dev/null +++ b/certora/mocks/IHashConsensusMock.sol @@ -0,0 +1,34 @@ +import { IHashConsensus } from "contracts/common/interfaces/IHashConsensus.sol"; + +contract IHashConsensusMock is IHashConsensus { + function getIsMember(address addr) external view returns (bool) { + return true; + } + + function getCurrentFrame() external view returns ( + uint256 refSlot, + uint256 reportProcessingDeadlineSlot + ) { + refSlot = 0; + reportProcessingDeadlineSlot = 0; + } + + function getChainConfig() external view returns ( + uint256 slotsPerEpoch, + uint256 secondsPerSlot, + uint256 genesisTime + ) { + slotsPerEpoch = 0; + secondsPerSlot = 0; + genesisTime = 0; + } + + function getFrameConfig() external view returns (uint256 initialEpoch, uint256 epochsPerFrame) { + initialEpoch = 0; + epochsPerFrame = 0; + } + + function getInitialRefSlot() external view returns (uint256) { + return 0; + } +} \ No newline at end of file diff --git a/certora/mocks/ILidoMock.sol b/certora/mocks/ILidoMock.sol new file mode 100644 index 0000000000..026d2be177 --- /dev/null +++ b/certora/mocks/ILidoMock.sol @@ -0,0 +1,141 @@ +pragma solidity >=0.8.0; // Same as ILido + +import { ILido } from "contracts/common/interfaces/ILido.sol"; + +contract ILidoMock is ILido { + // IERC20 + function totalSupply() external view returns (uint256) { + return 0; + } + function balanceOf(address account) external view returns (uint256) { + return 0; + } + function transfer(address to, uint256 value) external returns (bool) { + return true; + } + function allowance(address owner, address spender) external view returns (uint256) { + return 0; + } + function approve(address spender, uint256 value) external returns (bool) { + return true; + } + function transferFrom(address from, address to, uint256 value) external returns (bool) { + return true; + } + + // IERC20Permit + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external {} + function nonces(address owner) external view returns (uint256) { + return 0; + } + function DOMAIN_SEPARATOR() external view returns (bytes32) { + return bytes32(0); + } + + // IVersioned + function getContractVersion() external view returns (uint256) { + return 0; + } + + // ILido + function sharesOf(address user) external view returns (uint256) { + return 0; + } + + function getSharesByPooledEth(uint256 _ethAmount) external view returns (uint256) { + return 0; + } + + function getPooledEthByShares(uint256 _sharesAmount) external view returns (uint256) { + return 0; + } + + function getPooledEthBySharesRoundUp(uint256 _sharesAmount) external view returns (uint256) { + return 0; + } + + function transferSharesFrom(address _sender, address _recipient, uint256 _sharesAmount) external returns (uint256) { + return 0; + } + + function transferShares( + address _recipient, uint256 _sharesAmount + ) external returns (uint256) { + return 0; + } + + function rebalanceExternalEtherToInternal() external payable {} + + function getTotalPooledEther() external view returns (uint256) { + return 0; + } + + function getExternalEther() external view returns (uint256) { + return 0; + } + + function getExternalShares() external view returns (uint256) { + return 0; + } + + function mintExternalShares(address _recipient, uint256 _amountOfShares) external {} + + function burnExternalShares(uint256 _amountOfShares) external {} + + function getTotalShares() external view returns (uint256) { + return 0; + } + + function getBeaconStat() + external + view + returns (uint256 depositedValidators, uint256 beaconValidators, uint256 beaconBalance) { + return (0,0,0); + } + + function processClStateUpdate( + uint256 _reportTimestamp, + uint256 _preClValidators, + uint256 _reportClValidators, + uint256 _reportClBalance + ) external {} + + function collectRewardsAndProcessWithdrawals( + uint256 _reportTimestamp, + uint256 _reportClBalance, + uint256 _adjustedPreCLBalance, + uint256 _withdrawalsToWithdraw, + uint256 _elRewardsToWithdraw, + uint256 _lastWithdrawalRequestToFinalize, + uint256 _simulatedShareRate, + uint256 _etherToLockOnWithdrawalQueue + ) external {} + + function emitTokenRebase( + uint256 _reportTimestamp, + uint256 _timeElapsed, + uint256 _preTotalShares, + uint256 _preTotalEther, + uint256 _postTotalShares, + uint256 _postTotalEther, + uint256 _postInternalShares, + uint256 _postInternalEther, + uint256 _sharesMintedAsFees + ) external {} + + function mintShares(address _recipient, uint256 _sharesAmount) external {} + + function burnShares(uint256 _amountOfShares) external {} + + function internalizeExternalBadDebt(uint256 _amountOfShares) external {} + + function rebalanceExternalEtherToInternal(uint256 _amountOfShares) external payable {} +} diff --git a/certora/mocks/OldBurnerMock.sol b/certora/mocks/OldBurnerMock.sol new file mode 100644 index 0000000000..f5936fab6c --- /dev/null +++ b/certora/mocks/OldBurnerMock.sol @@ -0,0 +1,7 @@ +import { Burner } from "contracts/0.8.9/Burner.sol"; + +contract OldBurnerMock is Burner { + constructor(address _locator, address _stETH) + Burner(_locator, _stETH) + {} +} diff --git a/certora/mocks/PayableMock.sol b/certora/mocks/PayableMock.sol new file mode 100644 index 0000000000..bcbba7fa38 --- /dev/null +++ b/certora/mocks/PayableMock.sol @@ -0,0 +1,3 @@ +contract PayableMock { + fallback() external payable {} +} \ No newline at end of file diff --git a/certora/mocks/StorageExtension.sol b/certora/mocks/StorageExtension.sol new file mode 100644 index 0000000000..3ea9e99014 --- /dev/null +++ b/certora/mocks/StorageExtension.sol @@ -0,0 +1,28 @@ +import "contracts/0.8.25/vaults/predeposit_guarantee/PredepositGuarantee.sol"; +import "contracts/0.8.25/vaults/LazyOracle.sol"; +import "contracts/0.8.25/vaults/OperatorGrid.sol"; +import "contracts/0.8.25/vaults/StakingVault.sol"; +import "contracts/0.8.25/vaults/VaultHub.sol"; + +contract StorageExtension { + /** + * @custom:certoralink 0x73a2a247d4b1b6fe056fe90935e9bd3694e896bafdd08f046c2afe6ec2db2100 + */ + LazyOracle.Storage lo_storage; + /** + * @custom:certoralink 0x6b64617c951381e2c1eff2be939fe368ab6d76b7d335df2e47ba2309eba1c700 + */ + OperatorGrid.ERC7201Storage og_storage; + /** + * @custom:certoralink 0xf66b5a365356c5798cc70e3ea6a236b181a826a69f730fc07cc548244bee5200 + */ + PredepositGuarantee.ERC7201Storage pg_storage; + /** + * @custom:certoralink 0x2ec50241a851d8d3fea472e7057288d4603f7a7f78e6d18a9c12cad84552b100 + */ + StakingVault.Storage sv_storage; + /** + * @custom:certoralink 0x9eb73ffa4c77d08d5d1746cf5a5e50a47018b610ea5d728ea9bd9e399b76e200 + */ + VaultHub.Storage vh_storage; +} diff --git a/certora/mocks/StorageExtensionLazyOracle.sol b/certora/mocks/StorageExtensionLazyOracle.sol new file mode 100644 index 0000000000..0735f46456 --- /dev/null +++ b/certora/mocks/StorageExtensionLazyOracle.sol @@ -0,0 +1,10 @@ +pragma solidity 0.8.25; + +import "contracts/0.8.25/vaults/LazyOracle.sol"; + +contract StorageExtensionLazyOracle { + /** + * @custom:certoralink 0x73a2a247d4b1b6fe056fe90935e9bd3694e896bafdd08f046c2afe6ec2db2100 + */ + LazyOracle.Storage lo_storage; +} diff --git a/certora/mocks/StorageExtensionNodeOperatorsRegistry.sol b/certora/mocks/StorageExtensionNodeOperatorsRegistry.sol new file mode 100644 index 0000000000..a1013f14ae --- /dev/null +++ b/certora/mocks/StorageExtensionNodeOperatorsRegistry.sol @@ -0,0 +1,9 @@ +pragma solidity 0.4.24; + + +contract StorageExtensionNodeOperatorsRegistry { + /** + * @custom:certoralink 0xe2a589ae0816b289a9d29b7c085f8eba4b5525accca9fa8ff4dba3f5a41287e8 + */ + uint256 total_operators_count; +} diff --git a/certora/mocks/StorageExtensionOperatorGrid.sol b/certora/mocks/StorageExtensionOperatorGrid.sol new file mode 100644 index 0000000000..9238f5b9bc --- /dev/null +++ b/certora/mocks/StorageExtensionOperatorGrid.sol @@ -0,0 +1,10 @@ +pragma solidity 0.8.25; + +import "contracts/0.8.25/vaults/OperatorGrid.sol"; + +contract StorageExtensionOperatorGrid { + /** + * @custom:certoralink 0x6b64617c951381e2c1eff2be939fe368ab6d76b7d335df2e47ba2309eba1c700 + */ + OperatorGrid.ERC7201Storage og_storage; +} diff --git a/certora/mocks/StorageExtensionPredepositGuarantee.sol b/certora/mocks/StorageExtensionPredepositGuarantee.sol new file mode 100644 index 0000000000..7e48ce2416 --- /dev/null +++ b/certora/mocks/StorageExtensionPredepositGuarantee.sol @@ -0,0 +1,10 @@ +pragma solidity 0.8.25; + +import "contracts/0.8.25/vaults/predeposit_guarantee/PredepositGuarantee.sol"; + +contract StorageExtensionPredepositGuarantee { + /** + * @custom:certoralink 0xf66b5a365356c5798cc70e3ea6a236b181a826a69f730fc07cc548244bee5200 + */ + PredepositGuarantee.ERC7201Storage pg_storage; +} diff --git a/certora/mocks/StorageExtensionStakingVault.sol b/certora/mocks/StorageExtensionStakingVault.sol new file mode 100644 index 0000000000..7c61de688c --- /dev/null +++ b/certora/mocks/StorageExtensionStakingVault.sol @@ -0,0 +1,10 @@ +pragma solidity 0.8.25; + +import "contracts/0.8.25/vaults/StakingVault.sol"; + +contract StorageExtensionStakingVault { + /** + * @custom:certoralink 0x2ec50241a851d8d3fea472e7057288d4603f7a7f78e6d18a9c12cad84552b100 + */ + StakingVault.Storage sv_storage; +} diff --git a/certora/mocks/StorageExtensionVaultHub.sol b/certora/mocks/StorageExtensionVaultHub.sol new file mode 100644 index 0000000000..901e85c674 --- /dev/null +++ b/certora/mocks/StorageExtensionVaultHub.sol @@ -0,0 +1,10 @@ +pragma solidity 0.8.25; + +import "contracts/0.8.25/vaults/VaultHub.sol"; + +contract StorageExtensionVaultHub { + /** + * @custom:certoralink 0x9eb73ffa4c77d08d5d1746cf5a5e50a47018b610ea5d728ea9bd9e399b76e200 + */ + VaultHub.Storage vh_storage; +} diff --git a/certora/mocks/WithdrawableRequestMock.sol b/certora/mocks/WithdrawableRequestMock.sol new file mode 100644 index 0000000000..73b5802062 --- /dev/null +++ b/certora/mocks/WithdrawableRequestMock.sol @@ -0,0 +1,5 @@ +contract WithdrawableRequestMock { + fallback(bytes calldata data) external payable returns (bytes memory) { + return new bytes(32); + } +} \ No newline at end of file diff --git a/certora/mocks/WithdrawalQueueMock.sol b/certora/mocks/WithdrawalQueueMock.sol new file mode 100644 index 0000000000..6b5b456ff2 --- /dev/null +++ b/certora/mocks/WithdrawalQueueMock.sol @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: 2023 Lido +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.9; + +import { WithdrawalQueueBase } from "contracts/0.8.9/WithdrawalQueueBase.sol"; + +contract WithdrawalQueueMock is WithdrawalQueueBase { +} diff --git a/certora/patches/Makefile b/certora/patches/Makefile new file mode 100644 index 0000000000..04aee643b1 --- /dev/null +++ b/certora/patches/Makefile @@ -0,0 +1,25 @@ +# patch in node_modules: +# git add -f +# perform the patch +# git diff --patch node_modules/ +# cleanup the original file +# apply patches: make patch +# undo patches: make patch-undo + +# only reasonable way to run git apply is from the git root +GIT_ROOT=$(shell git rev-parse --show-toplevel) +CWD=$(shell realpath --relative-to="${GIT_ROOT}" .) + +patch: + cd ${GIT_ROOT} ; \ + for f in ${CWD}/patch-*.patch; do \ + echo applying $$f ; \ + git apply $$f ; \ + done + +patch-undo: + cd ${GIT_ROOT} ; \ + for f in ${CWD}/patch-*.patch; do \ + echo applying $$f ; \ + git apply -R $$f ; \ + done diff --git a/certora/patches/patch-strategy-lib.patch b/certora/patches/patch-strategy-lib.patch new file mode 100644 index 0000000000..25fc33e54c --- /dev/null +++ b/certora/patches/patch-strategy-lib.patch @@ -0,0 +1,13 @@ +diff --git a/contracts/common/lib/MinFirstAllocationStrategy.sol b/contracts/common/lib/MinFirstAllocationStrategy.sol +index 606b35d48..2865d8b9b 100644 +--- a/contracts/common/lib/MinFirstAllocationStrategy.sol ++++ b/contracts/common/lib/MinFirstAllocationStrategy.sol +@@ -31,7 +31,7 @@ library MinFirstAllocationStrategy { + uint256[] memory buckets, + uint256[] memory capacities, + uint256 allocationSize +- ) public pure returns (uint256 allocated, uint256[] memory) { ++ ) internal pure returns (uint256 allocated, uint256[] memory) { // PATCHED BY CERTORA + uint256 allocatedToBestCandidate = 0; + while (allocated < allocationSize) { + allocatedToBestCandidate = allocateToBestCandidate(buckets, capacities, allocationSize - allocated); diff --git a/certora/patches/patch-total-shares-access.patch b/certora/patches/patch-total-shares-access.patch new file mode 100644 index 0000000000..dd2d4f5a79 --- /dev/null +++ b/certora/patches/patch-total-shares-access.patch @@ -0,0 +1,38 @@ +diff --git a/contracts/0.4.24/StETH.sol b/contracts/0.4.24/StETH.sol +index b8c8d47f2..0094ebcf8 100644 +--- a/contracts/0.4.24/StETH.sol ++++ b/contracts/0.4.24/StETH.sol +@@ -474,6 +474,11 @@ contract StETH is IERC20, Pausable { + return TOTAL_SHARES_POSITION_LOW128.getLowUint128(); + } + ++ // PATCHED BY CERTORA ++ function _setTotalShares(uint256 newTotalShares) internal { ++ TOTAL_SHARES_POSITION_LOW128.setLowUint128(uint128(newTotalShares)); ++ } ++ + /** + * @return the amount of shares owned by `_account`. + */ +@@ -522,7 +527,9 @@ contract StETH is IERC20, Pausable { + newTotalShares = _getTotalShares().add(_sharesAmount); + require(newTotalShares & UINT128_HIGH_MASK == 0, "SHARES_OVERFLOW"); + +- TOTAL_SHARES_POSITION_LOW128.setLowUint128(newTotalShares); ++ // PATCHED BY CERTORA ++ // TOTAL_SHARES_POSITION_LOW128.setLowUint128(newTotalShares); ++ _setTotalShares(newTotalShares); + + shares[_recipient] = shares[_recipient].add(_sharesAmount); + +@@ -551,7 +558,9 @@ contract StETH is IERC20, Pausable { + require(_sharesAmount <= accountShares, "BALANCE_EXCEEDED"); + + newTotalShares = _getTotalShares().sub(_sharesAmount); +- TOTAL_SHARES_POSITION_LOW128.setLowUint128(newTotalShares); ++ // PATCHED BY CERTORA ++ // TOTAL_SHARES_POSITION_LOW128.setLowUint128(newTotalShares); ++ _setTotalShares(newTotalShares); + + shares[_account] = accountShares.sub(_sharesAmount); + } diff --git a/certora/scripts/patch-undo.sh b/certora/scripts/patch-undo.sh new file mode 100755 index 0000000000..505a0c2c32 --- /dev/null +++ b/certora/scripts/patch-undo.sh @@ -0,0 +1,2 @@ +git apply -R ./certora/patches/patch-strategy-lib.patch +git apply -R ./certora/patches/patch-total-shares-access.patch diff --git a/certora/scripts/patch.sh b/certora/scripts/patch.sh new file mode 100755 index 0000000000..6838ef8575 --- /dev/null +++ b/certora/scripts/patch.sh @@ -0,0 +1,2 @@ +git apply ./certora/patches/patch-strategy-lib.patch +git apply ./certora/patches/patch-total-shares-access.patch diff --git a/certora/specs/common/ERC20Params.spec b/certora/specs/common/ERC20Params.spec new file mode 100644 index 0000000000..9719e650ba --- /dev/null +++ b/certora/specs/common/ERC20Params.spec @@ -0,0 +1,8 @@ +/// Maximum total supply +definition MAX_SUPPLY() returns mathint = max_uint128; +/// Native token address +definition NATIVE() returns address = 0; +/// The default precision (18 decimals) +definition DEFAULT_PRECISION() returns uint256 = 10^18; +/// A second option for precision (27 decimals) +definition SECONDARY_PRECISION() returns uint256 = 10^27; diff --git a/certora/specs/common/ERC20Standard.spec b/certora/specs/common/ERC20Standard.spec new file mode 100644 index 0000000000..0e157f372d --- /dev/null +++ b/certora/specs/common/ERC20Standard.spec @@ -0,0 +1,66 @@ +import "./ERC20Storage.spec"; + +/* +┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ Summarizations +└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +*/ + +function totalSupplyCVL(address token, uint256 timestamp) returns uint256 +{ + require token != NATIVE(); + return supplyByToken[token]; +} + +function transferCVL(address token, uint256 timestamp, address from, address to, uint256 amount) returns bool +{ + require sumOfPairLessEqualThanSupply(token, from, to); + return transferCVLStandard(token, from, to, amount); +} + +function transferFromCVL(address token, uint256 timestamp, address spender, address from, address to, uint256 amount) returns bool +{ + require sumOfPairLessEqualThanSupply(token, from, to); + return transferFromCVLStandard(token, spender, from, to, amount); +} + +function balanceOfCVL(address token, uint256 timestamp, address account) returns uint256 { + /// The balance of any user cannot surpass than the total supply. + require balanceByToken[token][account] <= supplyByToken[token]; + require token != NATIVE(); + return balanceByToken[token][account]; +} + +function approveCVL(address token, address account, address spender, uint256 amount) returns bool { + allowanceByToken[token][account][spender] = amount; + return true; +} + +function allowanceCVL(address token, address account, address spender) returns uint256 { + require token != NATIVE(); + return allowanceByToken[token][account][spender]; +} + +/* +┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ Function implementations +└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +*/ + +function transferFromCVLStandard(address token, address spender, address from, address to, uint256 amount) returns bool { + require spender != from => allowanceByToken[token][from][spender] >= amount; + //if (allowanceByToken[token][from][spender] < amount) return false; + bool success = transferCVLStandard(token, from, to, amount); + if(success && spender != from) { + allowanceByToken[token][from][spender] = assert_uint256(allowanceByToken[token][from][spender] - amount); + } + return success; +} + +function transferCVLStandard(address token, address from, address to, uint256 amount) returns bool { + require balanceByToken[token][from] >= amount; + //if(balanceByToken[token][from] < amount) return false; + balanceByToken[token][from] = assert_uint256(balanceByToken[token][from] - amount); + balanceByToken[token][to] = require_uint256(balanceByToken[token][to] + amount); // We neglect overflows. + return true; +} diff --git a/certora/specs/common/ERC20Storage.spec b/certora/specs/common/ERC20Storage.spec new file mode 100644 index 0000000000..2c9a62eec9 --- /dev/null +++ b/certora/specs/common/ERC20Storage.spec @@ -0,0 +1,20 @@ +import "./ERC20Params.spec"; + +/// Bare storage of ERC20 tokens +ghost mapping(address /* token */ => uint256) supplyByToken { + axiom forall address token. supplyByToken[token] <= MAX_SUPPLY(); +} +ghost mapping(address /* token */ => mapping(address /* account */ => uint256)) balanceByToken; +ghost mapping(address /* token */ => mapping(address /* account */ => mapping(address /* spender */ => uint256))) allowanceByToken; + +/// Returns the decimals of a token [STATIC] +persistent ghost decimalsCVL(address /* token */) returns uint256; + +/// Returns whether a token is rebasing or not. [STATIC] +persistent ghost isRebasing(address /* token */) returns bool; + +function sumOfPairLessEqualThanSupply(address token, address user1, address user2) returns bool { + return balanceByToken[token][user1] <= supplyByToken[token] && + (user1 != user2 => + balanceByToken[token][user1] + balanceByToken[token][user2] <= supplyByToken[token]); +} diff --git a/certora/specs/common/StakingRouter-summary.spec b/certora/specs/common/StakingRouter-summary.spec new file mode 100644 index 0000000000..57bf48bcb1 --- /dev/null +++ b/certora/specs/common/StakingRouter-summary.spec @@ -0,0 +1,114 @@ +/* Summarizes main functions of the `StakingRouter` + +Note that most functions are summarized as `NONDET`, but some return values are constant. +NOTE These summaries are not sound in general. +*/ + +methods { + function _.getStakingRewardsDistribution( + ) external => CVLgetStakingRewardsDistribution() expect ( + address[], uint256[], uint96[], uint96, uint256 + ); + function _.getStakingModuleMaxDepositsCount(uint256, uint256) external => NONDET; + + // NOTE: Strictly speaking this summary of `reportRewardsMinted` is not sound. However + // the only side effects occur via calls to `NodeOperatorsRegistry.onRewardsMinted`. + function _.reportRewardsMinted(uint256[], uint256[]) external => NONDET; + + // TODO: The summary of `deposit` is not sound, in particular it does not transfer + // the deposited amount to the `DEPOSIT_CONTRACT`. + // NOTE: `StakingRouter.deposit` is called by `Lido.deposit` + function _.deposit(uint256, uint256, bytes) external => NONDET; + + function _.getStakingModuleIds() external => CVLgetStakingModuleIds() expect (uint256[]); + function _.getStakingModule( + uint256 moduleId + ) external => CVLgetStakingModule(moduleId) expect StakingRouter.StakingModule; +} + +/// @dev See `StakingRouter.FEE_PRECISION_POINTS` +definition FEE_PRECISION_POINTS() returns uint256 = 10^20; + +/// @dev The `totalFee` returned by `StakingRouter.getStakingRewardsDistribution` +ghost uint96 totalFeeGhost; + +/// @dev Summary of `StakingRouter.getStakingRewardsDistribution` where `totalFee` +/// and `precisionPoints` are constant and the return values are non-deterministic. +function CVLgetStakingRewardsDistribution() returns ( + address[], // `recipients` + uint256[], // `stakingModuleIds` + uint96[], // `stakingModuleFees` + uint96, // `totalFee` + uint256 // `precisionPoints` +) { + address[] recipients; + address a0; + address a1; + require(a0 != 0 && a1 != 0, "Valid non-zero recipient addresses"); + require( + (recipients.length > 0 => recipients[0] == a0) && + (recipients.length > 1 => recipients[1] == a1), + "Ensure the `recipients` array does not contain non-address values" + ); + + uint256[] stakingModuleIds; + uint96[] stakingModuleFees; + require( + stakingModuleIds.length == stakingModuleFees.length && + recipients.length == stakingModuleFees.length, + "Prevent revert due to lengths mismatch" + ); + + uint96 fee0; + uint96 fee1; + require( + (stakingModuleFees.length > 0 => stakingModuleFees[0] == fee0) && + (stakingModuleFees.length > 1 => stakingModuleFees[1] == fee1), + "Ensure the `stakingModuleFees` array does not contain illegal values" + ); + uint96 sumFees = require_uint96(fee0 + fee1); + require(totalFeeGhost >= sumFees, "Total fee is at least sum module fees"); + + return ( + recipients, + stakingModuleIds, + stakingModuleFees, + totalFeeGhost, + FEE_PRECISION_POINTS() + ); +} + + +ghost uint256 numModules; +ghost uint256 module0Id; +ghost uint256 module1Id; + +function CVLgetStakingModuleIds() returns uint256[] { + uint256[] ret; + require(ret.length == numModules && numModules <= 2, "Assuming loop_iter <= 2"); + require(numModules > 0 => ret[0] == module0Id, "Fixed zero'th module id"); + require(numModules > 1 => ret[1] == module1Id, "Fixed first module id"); + return ret; +} + + +ghost mapping(uint256 => uint24) stakingModuleId; +ghost mapping(uint256 => address) stakingModuleAddress; +ghost mapping(uint256 => uint16) stakingModuleFee; +ghost mapping(uint256 => uint16) stakingModuleTreasuryFee; +ghost mapping(uint256 => uint16) stakingModulestakeShareLimit; +ghost mapping(uint256 => uint256) stakingModuleexitedValidatorsCount; + +function CVLgetStakingModule(uint256 moduleId) returns StakingRouter.StakingModule { + StakingRouter.StakingModule ret; + require( + ret.id == stakingModuleId[moduleId] && + ret.stakingModuleAddress == stakingModuleAddress[moduleId] && + ret.stakingModuleFee == stakingModuleFee[moduleId] && + ret.treasuryFee == stakingModuleTreasuryFee[moduleId] && + ret.stakeShareLimit == stakingModulestakeShareLimit[moduleId] && + ret.exitedValidatorsCount == stakingModuleexitedValidatorsCount[moduleId], + "Staking modules unchanged" + ); + return ret; +} diff --git a/certora/specs/common/WithdrawalQueue-summary.spec b/certora/specs/common/WithdrawalQueue-summary.spec new file mode 100644 index 0000000000..1f969770cd --- /dev/null +++ b/certora/specs/common/WithdrawalQueue-summary.spec @@ -0,0 +1,27 @@ +/* Common summaries for `WithdrawalQueue` */ + +methods { + // `WithdrawalQueueBase` + function _.prefinalize( + uint256[] batches, + uint256 maxShareRate + ) external => CVLprefinalize(batches, maxShareRate) expect (uint256, uint256); + function _.unfinalizedStETH() external => DISPATCHER(true); + + // The following methods are not implemented in `WithdrawalQueueMock` + function _.finalize(uint256, uint256) external => NONDET; + function _.getWithdrawalStatus(uint256[]) external => NONDET; +} + + +/// @dev Summarizes `WithdrawalQueueBase.prefinalize` +function CVLprefinalize(uint256[] batches, uint256 maxShareRate) returns (uint256, uint256) { + uint256 ethToLock; + uint256 sharesToBurn; + require(sharesToBurn >= batches.length, "Assume at least one share per batch"); + require( + ethToLock <= sharesToBurn * maxShareRate, + "Maximal share rate is not surpassed" + ); + return (ethToLock, sharesToBurn); +} diff --git a/certora/specs/common/erc20-summary.spec b/certora/specs/common/erc20-summary.spec new file mode 100644 index 0000000000..6865df3ebe --- /dev/null +++ b/certora/specs/common/erc20-summary.spec @@ -0,0 +1,24 @@ +import "./ERC20Standard.spec"; + +methods { + function _.transfer(address to, uint256 amount) external with (env e) + => transferCVL(calledContract, e.block.timestamp, e.msg.sender, to, amount) expect bool; + + function _.transferFrom(address from, address to, uint256 amount) external with (env e) + => transferFromCVL(calledContract, e.block.timestamp, e.msg.sender, from, to, amount) expect bool; + + function _.balanceOf(address account) external with (env e) => + balanceOfCVL(calledContract, e.block.timestamp, account) expect uint256; + + function _.allowance(address account, address spender) external => + allowanceCVL(calledContract, account, spender) expect uint256; + + function _.decimals() external => + decimalsCVL(calledContract) expect uint256; + + function _.totalSupply() external with (env e) => + totalSupplyCVL(calledContract, e.block.timestamp) expect uint256; + + function _.approve(address spender, uint amount) external with (env e) => + approveCVL(calledContract, e.msg.sender, spender, amount) expect bool; +} diff --git a/certora/specs/common/lido-storage-ghost.spec b/certora/specs/common/lido-storage-ghost.spec new file mode 100644 index 0000000000..de8b4f4ebe --- /dev/null +++ b/certora/specs/common/lido-storage-ghost.spec @@ -0,0 +1,157 @@ +/* Summarizes some of `Lido` storage slots as ghosts */ +using LidoHarness as _Lido; + +methods { + // Total and external shares + function Lido._getExternalShares() internal returns (uint256) => externalSharesGhost; + function Lido._setExternalShares( + uint256 _externalShares + ) internal => CVLsetExternalShares(_externalShares); + function Lido._getTotalAndExternalShares( + ) internal returns (uint256, uint256) => CVLgetTotalAndExternalShares(); + function StETH._getTotalShares() internal returns (uint256) => totalSharesGhost; + + // MUNGING RELATED + function StETH._setTotalShares(uint256 newTotalShares) internal => CVLsetTotalShares(newTotalShares); + + // `LidoLocator` and `MaxExternalRatioBP` + function Lido._setMaxExternalRatioBP( + uint256 _newMaxExternalRatioBP + ) internal => CVLsetMaxExternalRatioBP(_newMaxExternalRatioBP); + function Lido._getMaxExternalRatioBP() internal returns (uint256) => maxExternalRationGhost; + + // `BUFFERED_ETHER_AND_DEPOSITED_VALIDATORS_POSITION` + function Lido._getBufferedEther() internal returns (uint256) => buffredEtherGhost; + function Lido._setBufferedEther( + uint256 _newBufferedEther + ) internal => CVLsetBufferedEther(_newBufferedEther); + function Lido._getDepositedValidators( + ) internal returns (uint256) => depositredValidatorsGhost; + function Lido._setDepositedValidators( + uint256 _newDepositedValidators + ) internal => CVLsetDepositedValidators(_newDepositedValidators); + function Lido._getBufferedEtherAndDepositedValidators( + ) internal returns (uint256, uint256) => CVLgetBufferedEtherAndDepositedValidators(); + + // `CL_BALANCE_AND_CL_VALIDATORS_POSITION` + function Lido._getClBalanceAndClValidators( + ) internal returns (uint256, uint256) => CVLgetClBalanceAndClValidators(); + function Lido._setClBalanceAndClValidators( + uint256 _newClBalance, uint256 _newClValidators + ) internal => CVLsetClBalanceAndClValidators(_newClBalance, _newClValidators); + + // `STAKING_STATE_POSITION` + // `StakeLimitUnstructuredStorage` + function StakeLimitUnstructuredStorage.getStorageStakeLimitStruct( + bytes32 + ) internal returns (StakeLimitState.Data memory) => CVLgetStorageStakeLimitStruct(); + function StakeLimitUnstructuredStorage.setStorageStakeLimitStruct( + bytes32 _position, StakeLimitState.Data memory _data + ) internal => CVLsetStorageStakeLimitStruct(_data); +} + +// ---- Ghost storage ---------------------------------------------------------- +// NOTE The ghost variables here follow the storage described in `Lido.sol` +// Lines 97-128 + +// `TOTAL_AND_EXTERNAL_SHARES_POSITION` +ghost uint128 externalSharesGhost; +ghost uint128 totalSharesGhost; + +// `LOCATOR_AND_MAX_EXTERNAL_RATIO_POSITION` +ghost uint96 maxExternalRationGhost; + +// `BUFFERED_ETHER_AND_DEPOSITED_VALIDATORS_POSITION` +ghost uint128 buffredEtherGhost; +ghost uint128 depositredValidatorsGhost; + +// `CL_BALANCE_AND_CL_VALIDATORS_POSITION` +ghost uint128 clBalanceGhost; +ghost uint128 clValidatorsGhost; + +// `STAKING_STATE_POSITION` +// Contains four variables packed into `uint256`: +ghost uint32 prevStakeBlockNumberGhost; +ghost uint96 prevStakeLimitGhost; +ghost uint32 maxStakeLimitGrowthBlocksGhost; +ghost uint96 maxStakeLimitGhost; + + +// ---- Summaries -------------------------------------------------------------- + +function CVLsetExternalShares(uint256 _externalShares) { + // NOTE: This is unsound - truncates to uint128 without validation + externalSharesGhost = require_uint128(_externalShares); +} + + +function CVLgetTotalAndExternalShares() returns (uint256, uint256) { + return (totalSharesGhost, externalSharesGhost); +} + + +function CVLsetTotalShares(uint256 newTotalShares) { + // NOTE: This is unsound - truncates to uint128 without validation + totalSharesGhost = require_uint128(newTotalShares); +} + + +function CVLsetMaxExternalRatioBP(uint256 _newMaxExternalRatioBP) { + // NOTE: This is unsound - truncates to uint96 without validation + maxExternalRationGhost = require_uint96(_newMaxExternalRatioBP); +} + + +function CVLsetBufferedEther(uint256 _newBufferedEther) { + // NOTE: This is unsound - truncates to uint128 without validation + buffredEtherGhost = require_uint128(_newBufferedEther); +} + + +function CVLsetDepositedValidators(uint256 _newDepositedValidators) { + // NOTE: This is unsound - truncates to uint128 without validation + depositredValidatorsGhost = require_uint128(_newDepositedValidators); +} + + +function CVLgetBufferedEtherAndDepositedValidators() returns (uint256, uint256) { + return (buffredEtherGhost, depositredValidatorsGhost); +} + + +function CVLgetClBalanceAndClValidators() returns (uint256, uint256) { + return (clBalanceGhost, clValidatorsGhost); +} + + +function CVLsetClBalanceAndClValidators(uint256 _newClBalance, uint256 _newClValidators) { + // NOTE: This is unsound - truncates to uint128 without validation + clBalanceGhost = require_uint128(_newClBalance); + clValidatorsGhost = require_uint128(_newClValidators); +} + + +function CVLsetClValidators(uint256 _newClValidators) { + // NOTE: This is unsound - truncates to uint128 without validation + clValidatorsGhost = require_uint128(_newClValidators); +} + + +/// @dev NOTE this assumes only references to `STAKING_STATE_POSITION` slot are used! +function CVLgetStorageStakeLimitStruct() returns StakeLimitState.Data { + StakeLimitState.Data ret; + require(ret.prevStakeBlockNumber == prevStakeBlockNumberGhost, "Correct struct"); + require(ret.prevStakeLimit == prevStakeLimitGhost, "Correct struct"); + require(ret.maxStakeLimitGrowthBlocks == maxStakeLimitGrowthBlocksGhost, "Correct struct"); + require(ret.maxStakeLimit == maxStakeLimitGhost, "Correct struct"); + return ret; +} + + +/// @dev NOTE this assumes only references to `STAKING_STATE_POSITION` slot are used! +function CVLsetStorageStakeLimitStruct(StakeLimitState.Data data) { + prevStakeBlockNumberGhost = data.prevStakeBlockNumber; + prevStakeLimitGhost = data.prevStakeLimit; + maxStakeLimitGrowthBlocksGhost = data.maxStakeLimitGrowthBlocks; + maxStakeLimitGhost = data.maxStakeLimit; +} diff --git a/certora/specs/common/lido-summaries.spec b/certora/specs/common/lido-summaries.spec new file mode 100644 index 0000000000..ceec7e606e --- /dev/null +++ b/certora/specs/common/lido-summaries.spec @@ -0,0 +1,125 @@ +/* Common summaries for `Lido` contract */ + +using LidoHarness as __Lido; // Double underscore to avoid conflicts + +methods { + // `LidoHarness` + function Lido._getLidoLocator() internal returns (address) => _LidoLocator; + function LidoHarness.sharesOf(address) external returns (uint256) envfree; + function LidoHarness.getTotalShares() external returns (uint256) envfree; + function LidoHarness.getExternalShares() external returns (uint256) envfree; + function LidoHarness.getInternalEther() external returns (uint256) envfree; + function LidoHarness.getTotalPooledEther() external returns (uint256) envfree; + function LidoHarness.getShareRateNumerator() external returns (uint256) envfree; + function LidoHarness.getShareRateDenominator() external returns (uint256) envfree; + function LidoHarness.getBufferedEther() external returns (uint256) envfree; + function LidoHarness.getDepositedValidators() external returns (uint256) envfree; + function LidoHarness.getPrevStakeLimit() external returns (uint96) envfree; + function LidoHarness.getPrevStakeBlockNumber() external returns (uint32) envfree; + function LidoHarness.getMaxStakeLimit() external returns (uint96) envfree; + function LidoHarness.getMaxStakeLimitGrowthBlocks() external returns (uint32) envfree; + function LidoHarness.getBalanceAndClValidators() external returns (uint256, uint256) envfree; + function LidoHarness.allowance(address, address) external returns (uint256) envfree; + + // Deleted to prevent static analysis issues + function LidoHarness.eip712Domain() external returns ( + string, string, uint256, address + ) => CVLeip712Domain() DELETE; + + function LidoHarness.getSharesByPooledEth( + uint256 _ethAmount + ) external returns (uint256) => CVLgetSharesByPooledEth(_ethAmount); + function LidoHarness.getPooledEthByShares( + uint256 _sharesAmount + ) external returns (uint256) => CVLgetPooledEthByShares(_sharesAmount); + function LidoHarness.getPooledEthBySharesRoundUp( + uint256 _sharesAmount + ) external returns (uint256) => CVLgetPooledEthBySharesRoundUp(_sharesAmount); +} + +// -- Summary functions -------------------------------------------------------- + +/// @dev Summarize the multiplication and division to reduce chances of timeout. +/// @notice While the original function will revert if `_ethAmount` exceeds `UINT128_MAX`, +/// this summary will not. +function CVLgetSharesByPooledEth(uint256 _ethAmount) returns uint256 { + uint256 numeratorInEther = __Lido.getShareRateNumerator(); + uint256 denominatorInShares = __Lido.getShareRateDenominator(); + + require( + numeratorInEther > 0, "Avoid division by zero in getSharesByPooledEth summary" + ); + require( + denominatorInShares < 2^128, + "Cannot be higher than 2^128 due to the way it is stored" + ); + + return require_uint256((_ethAmount * denominatorInShares) / numeratorInEther); +} + + +/// @dev Summarizes `Lido.getPooledEthByShares` +/// @notice While the original function will revert if `_sharesAmount` exceeds `UINT128_MAX`, +/// this summary will not. +function CVLgetPooledEthByShares(uint256 _sharesAmount) returns uint256 { + require( + _sharesAmount <= max_uint128, + "Lido.getPooledEthBySharesRoundUp reverts if _sharesAmount is bigger" + ); + uint256 numeratorInEther = __Lido.getShareRateNumerator(); + uint256 denominatorInShares = __Lido.getShareRateDenominator(); + require( + denominatorInShares > 0, + "Avoid division by zero in getPooledEthBySharesRoundUp summary" + ); + require( + numeratorInEther < 2^128, + "Prevent numeratorInEther * _shareAmount from overflowing in getPooledEthBySharesRoundUp" + ); + require( + denominatorInShares < 2^128, + "Cannot be higher than 2^128 due to the way it is stored" + ); + + return require_uint256( + (_sharesAmount * numeratorInEther) / denominatorInShares + ); +} + + +/// @dev Summarize the multiplication and division to reduce chances of timeout. +/// @notice While the original function will revert if `_sharesAmount` exceeds `UINT128_MAX`, +/// this summary will not. +function CVLgetPooledEthBySharesRoundUp(uint256 _sharesAmount) returns uint256 { + uint256 numeratorInEther = __Lido.getShareRateNumerator(); + uint256 denominatorInShares = __Lido.getShareRateDenominator(); + + require( + denominatorInShares > 0, + "Avoid division by zero in getPooledEthBySharesRoundUp summary" + ); + require( + numeratorInEther < 2^128, + "Prevent numeratorInEther * _shareAmount from overflowing in getPooledEthBySharesRoundUp" + ); + require( + denominatorInShares < 2^128, + "Cannot be higher than 2^128 due to the way it is stored" + ); + + return require_uint256( + // Add `denominatorInShares - 1` to round up + (_sharesAmount * numeratorInEther + denominatorInShares - 1) + / denominatorInShares + ); +} + + +/// @dev Summarize `Lido.eip712Domain` as non-deterministic +function CVLeip712Domain() returns (string, string, uint256, address) { + string name; + string version; + uint256 chainId; + address verifyingContract; + return (name, version, chainId, verifyingContract); +} diff --git a/certora/specs/common/smoothen-summary.spec b/certora/specs/common/smoothen-summary.spec new file mode 100644 index 0000000000..85c0124498 --- /dev/null +++ b/certora/specs/common/smoothen-summary.spec @@ -0,0 +1,324 @@ +/* Summary for `OracleReportSanityChecker.smoothenTokenRebase` */ + +using OracleReportSanityChecker as _OracleReportSanityChecker; + +methods { + function OracleReportSanityChecker.getMaxPositiveTokenRebase() external returns (uint256) envfree; +} + +// ---- Ghost variables -------------------------------------------------------- +// These are ghost mappings for the returns values of +// `OracleReportSanityChecker.smoothenTokenRebase`, ensuring we get the same values for +// the same parameters. + +ghost mapping( + uint256 /* `_withdrawalVaultBalance` */ => uint256 +) withdrawalsGhost; + +ghost mapping( + uint256 /* `_elRewardsVaultBalance` */ => uint256 +) elRewardsGhost; + +ghost mapping( + uint256 /* `_sharesRequestedToBurn` */ => uint256 +) sharesWQBurnGhost; + +/// @dev By `OracleReportSanityChecker.sol` Lines 440-443 the value of `sharesToBurn` +/// depends only on the sum `_newSharesToBurnForWithdrawals + _sharesRequestedToBurn` and +/// the shares burn limit. Also `sharesToBurn` is weakly monotonic increasing in this sum. +ghost mapping( + mathint /* `_newSharesToBurnForWithdrawals + _sharesRequestedToBurn` */ => uint256 +) sharesToBurnGhost { + // Weakly monotonic increasing + axiom forall mathint burn1. forall mathint burn2. ( + burn1 > burn2 => sharesToBurnGhost[burn1] >= sharesToBurnGhost[burn2] + ); +} + +/// @dev Simplified summary of `OracleReportSanityChecker.smoothenTokenRebase` +function CVLSimplifiedsmoothenTokenRebase( + uint256 _preTotalPooledEther, + uint256 _preTotalShares, + uint256 _preCLBalance, + uint256 _postCLBalance, + uint256 _withdrawalVaultBalance, + uint256 _elRewardsVaultBalance, + uint256 _sharesRequestedToBurn, + uint256 _etherToLockForWithdrawals, + uint256 _newSharesToBurnForWithdrawals +) returns ( + uint256, // `withdrawals` + uint256, // `elRewards` + uint256, // `sharesFromWQToBurn` + uint256 // `sharesToBurn` +) { + uint256 _rebaseLimit = _OracleReportSanityChecker.getMaxPositiveTokenRebase(); + require( + _rebaseLimit != 0 && _rebaseLimit <= UNLIMITED_REBASE(), + "See PositiveTokenRebaseLimiter.sol Lines 88-89" + ); + uint256 rebaseLimit = _preTotalPooledEther == 0 ? UNLIMITED_REBASE() : _rebaseLimit; + uint256 maxTotalPooledEther = require_uint256( + rebaseLimit == max_uint64 ? + max_uint256 : + _preTotalPooledEther + (rebaseLimit * _preTotalPooledEther) / LIMITER_PRECISION_BASE() + ); + + uint256 withdrawals = withdrawalsGhost[_withdrawalVaultBalance]; + require(withdrawals <= _withdrawalVaultBalance); + + uint256 elRewards = elRewardsGhost[_elRewardsVaultBalance]; + require(elRewards <= _elRewardsVaultBalance); + + mathint currentTotalPooledEther = ( + _preTotalPooledEther + + withdrawals + + elRewards + + _postCLBalance + - _preCLBalance + - _etherToLockForWithdrawals + ); + require(currentTotalPooledEther <= maxTotalPooledEther && currentTotalPooledEther >= 0); + + uint256 sharesToBurn = ( + sharesToBurnGhost[_newSharesToBurnForWithdrawals +_sharesRequestedToBurn] + ); + uint256 sharesFromWQToBurn = sharesWQBurnGhost[_sharesRequestedToBurn]; + mathint sharesToBurnLimit = getSharesToBurnLimit( + currentTotalPooledEther, + _preTotalShares, + _preTotalPooledEther, + maxTotalPooledEther, + rebaseLimit + ); + require(sharesFromWQToBurn <= _sharesRequestedToBurn && sharesFromWQToBurn <= sharesToBurn); + require(sharesToBurn <= _newSharesToBurnForWithdrawals + _sharesRequestedToBurn); + require(sharesToBurn <= sharesToBurnLimit); + + return (withdrawals, elRewards, sharesFromWQToBurn, sharesToBurn); +} + + +/// @dev Complete summary of `OracleReportSanityChecker.smoothenTokenRebase` +function CVLsmoothenTokenRebase( + uint256 _preTotalPooledEther, + uint256 _preTotalShares, + uint256 _preCLBalance, + uint256 _postCLBalance, + uint256 _withdrawalVaultBalance, + uint256 _elRewardsVaultBalance, + uint256 _sharesRequestedToBurn, + uint256 _etherToLockForWithdrawals, + uint256 _newSharesToBurnForWithdrawals +) returns ( + uint256, // `withdrawals` + uint256, // `elRewards` + uint256, // `sharesFromWQToBurn` + uint256 // `sharesToBurn` +) { + uint256 maxPositiveTokenRebase = _OracleReportSanityChecker.getMaxPositiveTokenRebase(); + uint256 preTotalPooledEther; + uint256 preTotalShares; + uint256 currentTotalPooledEther; + uint256 positiveRebaseLimit; + uint256 maxTotalPooledEther; + ( + preTotalPooledEther, + preTotalShares, + currentTotalPooledEther, + positiveRebaseLimit, + maxTotalPooledEther + ) = CVLMimicInitLimiterState(maxPositiveTokenRebase, _preTotalPooledEther, _preTotalShares); + + mathint currentTotalPooledEther1 = ( + _postCLBalance < _preCLBalance ? + decreaseEther( + _preCLBalance - _postCLBalance, + currentTotalPooledEther, + positiveRebaseLimit + ) : + increaseEtherPooledOnly( + _postCLBalance - _preCLBalance, + currentTotalPooledEther, + maxTotalPooledEther, + positiveRebaseLimit + ) + ); + + mathint withdrawals; + mathint currentTotalPooledEther2; + (withdrawals, currentTotalPooledEther2) = increaseEther( + _withdrawalVaultBalance, + currentTotalPooledEther1, + maxTotalPooledEther, + positiveRebaseLimit + ); + + mathint elRewards; + mathint currentTotalPooledEther3; + (elRewards, currentTotalPooledEther3) = increaseEther( + _elRewardsVaultBalance, + currentTotalPooledEther2, + maxTotalPooledEther, + positiveRebaseLimit + ); + + mathint _simulatedSharesToBurn = getSharesToBurnLimit( + currentTotalPooledEther3, + preTotalShares, + preTotalPooledEther, + maxTotalPooledEther, + positiveRebaseLimit + ); + mathint simulatedSharesToBurn = ( + _simulatedSharesToBurn < _sharesRequestedToBurn ? + _simulatedSharesToBurn : _sharesRequestedToBurn + ); + + mathint currentTotalPooledEther4 = decreaseEther( + _etherToLockForWithdrawals, + currentTotalPooledEther, + positiveRebaseLimit + ); + + mathint _sharesToBurn = getSharesToBurnLimit( + currentTotalPooledEther3, + preTotalShares, + preTotalPooledEther, + maxTotalPooledEther, + positiveRebaseLimit + ); + mathint sharesToBurn = ( + _sharesToBurn < _newSharesToBurnForWithdrawals + _sharesRequestedToBurn ? + _sharesToBurn : + _newSharesToBurnForWithdrawals + _sharesRequestedToBurn + ); + + mathint sharesFromWQToBurn = sharesToBurn - simulatedSharesToBurn; + return ( + require_uint256(withdrawals), + require_uint256(elRewards), + require_uint256(sharesFromWQToBurn), + require_uint256(sharesToBurn) + ); +} + + +/// @dev Mimics `PositiveTokenRebaseLimiter.decreaseEther` used in `smoothenTokenRebase` +function decreaseEther( + mathint _etherAmount, + mathint currentTotalPooledEther, + uint256 positiveRebaseLimit +) returns mathint { + if (positiveRebaseLimit == UNLIMITED_REBASE()) { + return currentTotalPooledEther; + } + require( + _etherAmount <= currentTotalPooledEther, + "See PositiveTokenRebaseLimiter Line 123" + ); + return currentTotalPooledEther - _etherAmount; +} + + +/// @dev Mimics `PositiveTokenRebaseLimiter.increaseEther` used in `smoothenTokenRebase` +function increaseEther( + mathint _etherAmount, + mathint currentTotalPooledEther, + uint256 maxTotalPooledEther, + uint256 positiveRebaseLimit +) returns (mathint, mathint) { + if (positiveRebaseLimit == UNLIMITED_REBASE()) { + return (_etherAmount, currentTotalPooledEther); + } + mathint sumPooledEther = _etherAmount + currentTotalPooledEther; + mathint newPooledEther = ( + (sumPooledEther < maxTotalPooledEther) ? sumPooledEther : maxTotalPooledEther + ); + + require( + newPooledEther >= currentTotalPooledEther, + "See PositiveTokenRebaseLimiter Line 149" + ); + mathint consumedEther = newPooledEther - currentTotalPooledEther; + return (consumedEther, newPooledEther); +} + + +/// @dev A version of `increaseEther` above only returning `newPooledEther` +function increaseEtherPooledOnly( + mathint _etherAmount, + mathint currentTotalPooledEther, + uint256 maxTotalPooledEther, + uint256 positiveRebaseLimit +) returns mathint { + mathint newPooledEther; + (_, newPooledEther) = increaseEther( + _etherAmount, currentTotalPooledEther, maxTotalPooledEther, positiveRebaseLimit + ); + return newPooledEther; +} + + +/// @dev See `PositiveTokenRebaseLimiter` Line 72 +definition LIMITER_PRECISION_BASE() returns uint256 = 10^9; + +/// @dev See `PositiveTokenRebaseLimiter` Line 74 +definition UNLIMITED_REBASE() returns uint256 = max_uint64; + +/// @dev Mimics `PositiveTokenRebaseLimiter.getSharesToBurnLimit` +function getSharesToBurnLimit( + mathint currentTotalPooledEther, + uint256 preTotalShares, + uint256 preTotalPooledEther, + uint256 maxTotalPooledEther, + uint256 positiveRebaseLimit +) returns mathint { + if (positiveRebaseLimit == UNLIMITED_REBASE()) { + return preTotalShares; + } + if (currentTotalPooledEther >= maxTotalPooledEther) { + return 0; + } + mathint rebaseLimitPlus1 = positiveRebaseLimit + LIMITER_PRECISION_BASE(); + mathint pooledEtherRate = ( + (currentTotalPooledEther * LIMITER_PRECISION_BASE()) / preTotalPooledEther + ); + return (preTotalShares * (rebaseLimitPlus1 - pooledEtherRate)) / rebaseLimitPlus1; +} + +/// @dev Mimics `PositiveTokenRebaseLimiter.initLimiterState` +function CVLMimicInitLimiterState( + uint256 _rebaseLimit, + uint256 _preTotalPooledEther, + uint256 _preTotalShares +) returns ( + uint256, // `preTotalPooledEther` + uint256, // `preTotalShares` + uint256, // `currentTotalPooledEther` + uint256, // `positiveRebaseLimit` + uint256 // `maxTotalPooledEther` +) { + require( + _rebaseLimit != 0 && _rebaseLimit <= UNLIMITED_REBASE(), + "See PositiveTokenRebaseLimiter.sol Lines 88-89" + ); + uint256 rebaseLimit = _preTotalPooledEther == 0 ? UNLIMITED_REBASE() : _rebaseLimit; + + uint256 currentTotalPooledEther = _preTotalPooledEther; + uint256 preTotalPooledEther = _preTotalPooledEther; + uint256 preTotalShares = _preTotalShares; + uint256 positiveRebaseLimit = rebaseLimit; + uint256 maxTotalPooledEther = require_uint256( + rebaseLimit == max_uint64 ? + max_uint256 : + _preTotalPooledEther + (rebaseLimit * _preTotalPooledEther) / LIMITER_PRECISION_BASE() + ); + return ( + preTotalPooledEther, + preTotalShares, + currentTotalPooledEther, + positiveRebaseLimit, + maxTotalPooledEther + ); +} diff --git a/certora/specs/core/Accounting-burnlimit.spec b/certora/specs/core/Accounting-burnlimit.spec new file mode 100644 index 0000000000..70f0de7f3e --- /dev/null +++ b/certora/specs/core/Accounting-burnlimit.spec @@ -0,0 +1,231 @@ +/* Spec for checking the burn limit (`PositiveTokenRebaseLimiter.getSharesToBurnLimit`) */ + +import "../common/StakingRouter-summary.spec"; +import "../common/lido-storage-ghost.spec"; +import "../common/lido-summaries.spec"; + +using AccountingHarness as _Accounting; +using Burner as _Burner; +using LidoLocator as _LidoLocator; +using OracleReportSanityChecker as _OracleReportSanityChecker; +using WithdrawalQueueMock as _WithdrawalQueue; + +methods { + // `Accounting` + function AccountingHarness.treasury() external returns (address) envfree; + + function Accounting._calculateTotalProtocolFeeShares( + Accounting.ReportValues calldata _report, + Accounting.CalculatedValues memory _update, + uint256 _internalSharesBeforeFees, + uint256 _totalFee, + uint256 _feePrecisionPoints + ) internal returns (uint256) => CVLcalculateTotalProtocolFeeShares( + _report, _update, _internalSharesBeforeFees, _totalFee, _feePrecisionPoints + ); + + // `LidoLocator` + function _.accounting() external => _Accounting expect address; + function _.burner() external => _Burner expect address; + function _.lido() external => _Lido expect address; + function _.withdrawalQueue() external => _WithdrawalQueue expect address; + function _.vaultHub() external => CONSTANT; + function _.treasury() external => CONSTANT; + function _.depositSecurityModule() external => CONSTANT; + function _.stakingRouter() external => CONSTANT; + function _.accountingOracle() external => CONSTANT; + + // `IKernel` (`@aragon/os/contracts/kernel/IKernel.sol`) called by `AragonApp` + function _.hasPermission(address, address, bytes32, bytes) external => NONDET; + + // `VaultHub` + function _.badDebtToInternalizeAsOfLastRefSlot( + ) external => badDebtToInternalizeGhost expect uint256; + function _.decreaseInternalizedBadDebt(uint256) external => NONDET; + + // `OracleReportSanityChecker` + function _.checkAccountingOracleReport( + uint256, uint256, uint256, uint256, uint256, uint256, uint256, uint256 + ) external => DISPATCHER(true); + function _.checkWithdrawalQueueOracleReport(uint256, uint256) external => DISPATCHER(true); + function OracleReportSanityChecker.getMaxPositiveTokenRebase( + ) external returns (uint256) envfree; + function _.smoothenTokenRebase( + uint256, uint256, uint256, uint256, uint256, uint256, uint256, uint256, uint256 + ) external => DISPATCHER(true); + + // `AccountingOracle` + // NOTE Summarizing `getLastProcessingRefSlot` as constant is not sound, but it is + // fine in this spec + function _.getLastProcessingRefSlot() external => CONSTANT; + + // `Burner` + function _.commitSharesToBurn(uint256) external => DISPATCHER(true); + function _.requestBurnShares(address, uint256) external => DISPATCHER(true); + function _.getSharesRequestedToBurn() external => DISPATCHER(true); + + // The following is a view function in `@aragon/os/contracts/kernel/Kernel.sol` + function _.getRecoveryVault() external => NONDET; + + // `ISecondOpinionOracle` + function _.getReport(uint256) external => NONDET; + + // `IPostTokenRebaseReceiver` + // This interface has a single function. Its only implementation is in + // `test/0.4.24/contracts/PostTokenRebaseReceiver__MockForAccounting.sol` where it + // does nothing apart from emitting an event. + function _.handlePostTokenRebase( + uint256, uint256, uint256, uint256, uint256, uint256, uint256 + ) external => NONDET; + + // `WithdrawalQueueERC721` + // NOTE Summarizing `isPaused` and `isBunkerModeActive` as constant is not sound, + // but it is fine for this particular spec + function _.isPaused() external => CONSTANT; // Not implemented in `WithdrawalQueueMock` + function _.prefinalize(uint256[], uint256) external => DISPATCHER(true); + function _.isBunkerModeActive() external => CONSTANT; // Not implemented in `WithdrawalQueueMock` +} + +// -- Summary functions -------------------------------------------------------- + +ghost uint256 badDebtToInternalizeGhost; + +definition presicsion() returns mathint = 10^27; + +ghost mathint feeRatio { + axiom feeRatio >= 0 && feeRatio <= presicsion(); +} + +/// @dev The unified consensus layer balance, see `Accounting.sol` Line 250 +definition unifiedClBalance( + Accounting.ReportValues report, + Accounting.CalculatedValues update +) returns mathint = report.clBalance + update.withdrawalsVaultTransfer; + +/// @dev Total rewards in this report, see `Accounting.sol` Line 256 +definition getTotalRewards( + Accounting.ReportValues report, + Accounting.CalculatedValues update +) returns mathint = ( + unifiedClBalance(report, update) - update.principalClBalance + update.elRewardsVaultTransfer +); + + +/// @dev Summary of `Accounting._calculateTotalProtocolFeeShares` +function CVLcalculateTotalProtocolFeeShares( + Accounting.ReportValues report, + Accounting.CalculatedValues update, + uint256 internalSharesBeforeFees, + uint256 _totalFee, + uint256 _feePrecisionPoints +) returns uint256 { + mathint unifiedClBalanceValue = unifiedClBalance(report, update); + if (unifiedClBalanceValue <= update.principalClBalance) { + return 0; + } + mathint totalRewards = getTotalRewards(report, update); + mathint feeEther = (totalRewards * _totalFee) / _feePrecisionPoints; // See Accounting Line 260 + + require(update.postInternalEther > feeEther, "Avoid division by zero"); + mathint sharesToMintAsFees = ( + (feeEther * internalSharesBeforeFees) / (update.postInternalEther - feeEther) + ); + return require_uint256(sharesToMintAsFees); +} + +// ---- Rules ------------------------------------------------------------------ + +/// @dev See `PositiveTokenRebaseLimiter.UNLIMITED_REBASE` +definition UNLIMITED_REBASE() returns mathint = max_uint64; + +/// @dev See `PositiveTokenRebaseLimiter.LIMITER_PRECISION_BASE` +definition limiterPrecision() returns mathint = 10^9; + +rule burnSharesAmountCorrectness(Accounting.ReportValues report) { + uint256 internalEthPre = _Lido.getInternalEther(); + mathint internalSharesPre = _Lido.getTotalShares() - _Lido.getExternalShares(); + + require(internalEthPre == 10000 && internalSharesPre == 100); + + env e; + Accounting.CalculatedValues update = _Accounting.simulateOracleReport(e, report); + + uint256 postInternalShares = update.postInternalShares; + uint256 postInternalEther = update.postInternalEther; + uint256 postTotalPooledEther = update.postTotalPooledEther; + uint256 postTotalShares = update.postTotalShares; + uint256 etherToFinalizeWQ = update.etherToFinalizeWQ; + uint256 sharesToFinalizeWQ = update.sharesToFinalizeWQ; + uint256 totalSharesToBurn = update.totalSharesToBurn; + uint256 sharesToMintAsFees = update.sharesToMintAsFees; + + require( + report.simulatedShareRate <= (postTotalPooledEther * 10^27) / postTotalShares, + "Assume report.simulatedShareRate is not higher than eventual rate" + ); + require(totalSharesToBurn < internalSharesPre, "Avoid division by zero in CVL"); + + uint256 maxPostiveRebase = _OracleReportSanityChecker.getMaxPositiveTokenRebase(); + mathint maxRateWithoutBurn = ( + (postInternalEther * (limiterPrecision() + maxPostiveRebase)) / internalSharesPre + ); + mathint rateWithBurn = ( + (postInternalEther * limiterPrecision()) / (internalSharesPre - totalSharesToBurn) + ); + + // NOTE We assume `update.totalSharesToBurn` is non-zero since otherwise + // `getSharesToBurnLimit` might have no effect + assert( + (maxPostiveRebase < UNLIMITED_REBASE() && postInternalEther > 0 && internalEthPre > 0) + => rateWithBurn <= maxRateWithoutBurn, + "Rate limit is not surpassed" + ); +} + +rule burnSharesAmountCorrectnessSimpleExample(Accounting.ReportValues report) { + uint256 internalEthPre = _Lido.getInternalEther(); + mathint internalSharesPre = _Lido.getTotalShares() - _Lido.getExternalShares(); + + require(internalEthPre == 10000 && internalSharesPre == 100); + require(_Lido.getTotalShares() <= 200); + + env e; + Accounting.CalculatedValues update = _Accounting.simulateOracleReport(e, report); + + uint256 postInternalShares = update.postInternalShares; + uint256 postInternalEther = update.postInternalEther; + uint256 postTotalPooledEther = update.postTotalPooledEther; + uint256 postTotalShares = update.postTotalShares; + uint256 etherToFinalizeWQ = update.etherToFinalizeWQ; + uint256 sharesToFinalizeWQ = update.sharesToFinalizeWQ; + uint256 sharesToBurnForWithdrawals = update.sharesToBurnForWithdrawals; + uint256 totalSharesToBurn = update.totalSharesToBurn; + uint256 sharesRequestedToBurn = report.sharesRequestedToBurn; + require(sharesRequestedToBurn + sharesToFinalizeWQ < internalSharesPre); + + require( + report.simulatedShareRate <= (postTotalPooledEther * 10^27) / postTotalShares, + "Assume report.simulatedShareRate is not higher than eventual rate" + ); + + mathint ratePre = (internalEthPre * limiterPrecision()) / internalSharesPre; + mathint ratePost = (postInternalEther * limiterPrecision()) / postInternalShares; + uint256 maxPostiveRebase = _OracleReportSanityChecker.getMaxPositiveTokenRebase(); + mathint maxratePost = ( + internalEthPre * (maxPostiveRebase + limiterPrecision()) + ) / internalSharesPre; + mathint rateIfAllBurnt = ( + (postInternalEther * limiterPrecision()) / + (postInternalShares + totalSharesToBurn - sharesRequestedToBurn) + ); + require(rateIfAllBurnt <= maxratePost + 1); + + // NOTE We assume `update.totalSharesToBurn` is non-zero since otherwise + // `getSharesToBurnLimit` might have no effect + assert( + (maxPostiveRebase < UNLIMITED_REBASE() && ratePre > 0 && update.totalSharesToBurn > 0) + // => (ratePost - ratePre) * limiterPrecision() <= ratePre * maxPostiveRebase, + => ratePost <= maxratePost + 10^10, + "Rate limit is not surpassed" + ); +} diff --git a/certora/specs/core/Accounting-fees-as-frac.spec b/certora/specs/core/Accounting-fees-as-frac.spec new file mode 100644 index 0000000000..4766bc40a1 --- /dev/null +++ b/certora/specs/core/Accounting-fees-as-frac.spec @@ -0,0 +1,128 @@ +/* A single rule for `Accounting` contract + +This rule cannot be proven in the standard setup which summarizes +`_calculateTotalProtocolFeeShares`. +*/ + +using AccountingHarness as _Accounting; + +methods { + // `Accounting` + function calculateTotalProtocolFeeShares( + Accounting.ReportValues, + Accounting.CalculatedValues, + uint256, + uint256, + uint256 + ) external returns (uint256) envfree; +} + + +/// @dev The unified consensus layer balance, see `Accounting.sol` Line 279 +definition unifiedClBalance( + Accounting.ReportValues report, + Accounting.CalculatedValues update +) returns mathint = report.clBalance + update.withdrawalsVaultTransfer; + + +/// @dev Total rewards in this report, see `Accounting.sol` Line 285 +definition getTotalRewards( + Accounting.ReportValues report, + Accounting.CalculatedValues update +) returns mathint = ( + unifiedClBalance(report, update) - update.principalClBalance + update.elRewardsVaultTransfer +); + + +/// @dev See `StakingRouter.FEE_PRECISION_POINTS` +definition FEE_PRECISION_POINTS() returns uint256 = 10^20; + + +/// @title The value of the shares minted as fees is roughly their designated fraction of +/// the total rewards +/// @notice The third assertion was removed as per Lido's acknowledgment in issue #1457 +/// that fees can be as low as half the designated fraction in extreme corner cases, +/// which is acceptable as it cannot be exploited and works in favor of stETH holders. +/// See: https://github.com/lidofinance/core/issues/1457 +rule feesAreFraction( + Accounting.ReportValues report, + Accounting.CalculatedValues update, + uint256 internalSharesBeforeFees, + uint256 _totalFee, + uint256 _feePrecisionPoints, + uint256 badDebtToInternalize, + uint256 preInternalEther +) { + uint256 toMintAsFees = calculateTotalProtocolFeeShares( + report, update, internalSharesBeforeFees, _totalFee, FEE_PRECISION_POINTS() + ); + mathint totalRewards = getTotalRewards(report, update); + require(totalRewards >= 0, "Non-negative rewards"); + + require(badDebtToInternalize == 0); + mathint postInternalShares = internalSharesBeforeFees + toMintAsFees + badDebtToInternalize; + require(postInternalShares > 0, "Avoid division by zero"); + + // See `Accounting.sol` Lines 190--194 + mathint postInternalEther = ( + preInternalEther // `_pre.totalPooledEther - _pre.externalEther` + + report.clBalance + update.withdrawalsVaultTransfer - update.principalClBalance + + update.elRewardsVaultTransfer + - update.etherToFinalizeWQ + ); + require(postInternalEther == update.postInternalEther, "Ensure correct values"); + + mathint feesRounded = (toMintAsFees * update.postInternalEther) / postInternalShares; + assert( + // `(totalRewards * _totalFee) / FEE_PRECISION_POINTS() + 1 >= feesRoundedUp` + totalRewards * _totalFee + FEE_PRECISION_POINTS() >= + feesRounded * FEE_PRECISION_POINTS(), + "Fee shares are not worth more than designated fraction rounded down" + ); + assert( + toMintAsFees > 0 => ( + totalRewards * _totalFee <= 2 * (feesRounded + 1) * FEE_PRECISION_POINTS() + ), + "Fee shares value rounded up are not worth less than half designated fraction" + ); +} + + +/// @title An example showing that the value of shares minted as fees may be too low, +/// even if there is no bad debt to internalize +rule feesAreTooLowExample( + Accounting.ReportValues report, + Accounting.CalculatedValues update, + uint256 internalSharesBeforeFees, + uint256 _totalFee, + uint256 _feePrecisionPoints, + uint256 preInternalEther +) { + uint256 toMintAsFees = calculateTotalProtocolFeeShares( + report, update, internalSharesBeforeFees, _totalFee, FEE_PRECISION_POINTS() + ); + mathint totalRewards = getTotalRewards(report, update); + require(totalRewards >= 0, "Non-negative rewards"); + + // Note we assume `badDebtToInternalize` is zero in this example + mathint postInternalShares = internalSharesBeforeFees + toMintAsFees; + require(postInternalShares > 0, "Avoid division by zero"); + + // See `Accounting.sol` Lines 190--194 + mathint postInternalEther = ( + preInternalEther // `_pre.totalPooledEther - _pre.externalEther` + + report.clBalance + update.withdrawalsVaultTransfer - update.principalClBalance + + update.elRewardsVaultTransfer + - update.etherToFinalizeWQ + ); + require(postInternalEther == update.postInternalEther, "Ensure correct values"); + + mathint feesRounded = (toMintAsFees * update.postInternalEther) / postInternalShares; + satisfy( + toMintAsFees > 0 && feesRounded > 0 && + totalRewards * _totalFee >= 2 * feesRounded * FEE_PRECISION_POINTS() && + totalRewards == 10000 && + _totalFee >= 100 && + preInternalEther == 100000 + ); +} diff --git a/certora/specs/core/Accounting-summarized.spec b/certora/specs/core/Accounting-summarized.spec new file mode 100644 index 0000000000..8528a35c35 --- /dev/null +++ b/certora/specs/core/Accounting-summarized.spec @@ -0,0 +1,35 @@ +/* Spec for `Accounting` contract summarizing `OracleReportSanityChecker.smoothenTokenRebase` */ + +import "./Accounting.spec"; + +methods { + // `OracleReportSanityChecker` + function _.smoothenTokenRebase( + uint256 _preTotalPooledEther, + uint256 _preTotalShares, + uint256 _preCLBalance, + uint256 _postCLBalance, + uint256 _withdrawalVaultBalance, + uint256 _elRewardsVaultBalance, + uint256 _sharesRequestedToBurn, + uint256 _etherToLockForWithdrawals, + uint256 _newSharesToBurnForWithdrawals + ) external => CVLSimplifiedsmoothenTokenRebase( + _preTotalPooledEther, + _preTotalShares, + _preCLBalance, + _postCLBalance, + _withdrawalVaultBalance, + _elRewardsVaultBalance, + _sharesRequestedToBurn, + _etherToLockForWithdrawals, + _newSharesToBurnForWithdrawals + ) expect (uint256, uint256, uint256, uint256); +} + + +use rule feesMintShares; +use rule reportNotRevertsByDeposit; +use rule reportNotRevertsBySubmit; +use rule simulationnIsCorrect; +use rule handleOracleReportRevertConditions; diff --git a/certora/specs/core/Accounting.spec b/certora/specs/core/Accounting.spec new file mode 100644 index 0000000000..a4c19ab148 --- /dev/null +++ b/certora/specs/core/Accounting.spec @@ -0,0 +1,384 @@ +/* Rules and basic summaries for `Accounting` contract */ + +import "../common/smoothen-summary.spec"; +import "../common/StakingRouter-summary.spec"; +import "../common/lido-storage-ghost.spec"; +import "../common/lido-summaries.spec"; +import "../common/WithdrawalQueue-summary.spec"; + +using AccountingHarness as _Accounting; +using Burner as _Burner; +using LidoLocator as _LidoLocator; +using LidoExecutionLayerRewardsVault as _ELRewardsVault; +using WithdrawalVault as _WithdrawalVault; +using WithdrawalQueueMock as _WithdrawalQueue; + +methods { + // `Accounting` + function AccountingHarness.treasury() external returns (address) envfree; + + function Accounting._calculateTotalProtocolFeeShares( + Accounting.ReportValues calldata _report, + Accounting.CalculatedValues memory _update, + uint256 _internalSharesBeforeFees, + uint256 _totalFee, + uint256 _feePrecisionPoints + ) internal returns (uint256) => CVLcalculateTotalProtocolFeeShares( + _report, _update, _internalSharesBeforeFees, _totalFee, _feePrecisionPoints + ); + + // `PositiveTokenRebaseLimiter` + function PositiveTokenRebaseLimiter.getSharesToBurnLimit( + PositiveTokenRebaseLimiter.TokenRebaseLimiterData memory _limiterState + ) internal returns (uint256) => CVLgetSharesToBurnLimit(_limiterState); + + // `LidoLocator` + function _.accounting() external => _Accounting expect address; + function _.burner() external => _Burner expect address; + function _.lido() external => _Lido expect address; + function _.withdrawalQueue() external => _WithdrawalQueue expect address; + function _.withdrawalVault() external => _WithdrawalVault expect address; + function _.elRewardsVault() external => _ELRewardsVault expect address; + function _.vaultHub() external => CONSTANT; + function _.treasury() external => CONSTANT; + function _.depositSecurityModule() external => CONSTANT; + function _.stakingRouter() external => CONSTANT; + function _.accountingOracle() external => CONSTANT; + + // `IKernel` (`@aragon/os/contracts/kernel/IKernel.sol`) called by `AragonApp` + function _.hasPermission(address, address, bytes32, bytes) external => NONDET; + + // `VaultHub` + function _.badDebtToInternalize() external => CONSTANT; + function _.decreaseInternalizedBadDebt(uint256) external => NONDET; + + // `OracleReportSanityChecker` + function _.checkAccountingOracleReport( + uint256, uint256, uint256, uint256, uint256, uint256, uint256, uint256 + ) external => DISPATCHER(true); + function _.checkWithdrawalQueueOracleReport(uint256, uint256) external => DISPATCHER(true); + function _.checkSimulatedShareRate( + uint256, uint256, uint256, uint256, uint256 + ) external => DISPATCHER(true); + + // `AccountingOracle` + // NOTE Summarizing `getLastProcessingRefSlot` as constant is not sound, but it is + // fine in this spec + function _.getLastProcessingRefSlot() external => CONSTANT; + + // `Burner` + function _.commitSharesToBurn(uint256) external => DISPATCHER(true); + function _.requestBurnShares(address, uint256) external => DISPATCHER(true); + function _.getSharesRequestedToBurn() external => DISPATCHER(true); + + // `ConversionHelpers` Lib (`node_modules/@aragon/os/contracts/common/ConversionHelpers.sol` + // called by `AragonApp` + // The summary below is not sound since we return a reference type, however it is + // only used as parameter for `hasPermission` above, which is summarized as `NONDET`. + function ConversionHelpers.dangerouslyCastUintArrayToBytes( + uint256[] memory + ) internal returns (bytes memory) => CVLNondetBytes(); + + // The following is a view function in `@aragon/os/contracts/kernel/Kernel.sol` + function _.getRecoveryVault() external => NONDET; + + // `ISecondOpinionOracle` + function _.getReport(uint256) external => NONDET; + + // `WithdrawalQueueERC721` + // NOTE Summarizing `isPaused` and `isBunkerModeActive` as constant is not sound, + // but it is fine for this particular spec + function _.isPaused() external => CONSTANT; // Not implemented in `WithdrawalQueueMock` + function _.isBunkerModeActive() external => CONSTANT; // Not implemented in `WithdrawalQueueMock` + + // `LidoExecutionLayerRewardsVault` + function _.withdrawRewards(uint256) external => DISPATCHER(true); + + // `WithdrawalVault` + function _.withdrawWithdrawals(uint256) external => DISPATCHER(true); + + // `IPostTokenRebaseReceiver` + // This interface has a single function. Its only implementation is in + // `test/0.4.24/contracts/PostTokenRebaseReceiver__MockForAccounting.sol` where it + // does nothing apart from emitting an event. + function _.handlePostTokenRebase( + uint256, uint256, uint256, uint256, uint256, uint256, uint256 + ) external => NONDET; +} + +// -- Summary functions -------------------------------------------------------- + +/// @dev A non-deterministic bytes array +function CVLNondetBytes() returns bytes { + bytes ret; + return ret; +} + + +/// @dev Summary of `PositiveTokenRebaseLimiter.getSharesToBurnLimit` +function CVLgetSharesToBurnLimit( + PositiveTokenRebaseLimiter.TokenRebaseLimiterData _limiterState +) returns uint256 { + if (_limiterState.positiveRebaseLimit == max_uint64) { + return _limiterState.preTotalShares; + } + if (_limiterState.currentTotalPooledEther >= _limiterState.maxTotalPooledEther) { + return 0; + } + uint256 rebaseLimitPlus1 = require_uint256( + _limiterState.positiveRebaseLimit + LIMITER_PRECISION_BASE() + ); + require(_limiterState.preTotalPooledEther != 0, "Avoid division by zero"); + uint256 pooledEtherRate = require_uint256( + (_limiterState.currentTotalPooledEther * LIMITER_PRECISION_BASE()) / + _limiterState.preTotalPooledEther + ); + + uint256 maxSharesToBurn = require_uint256( + (_limiterState.preTotalShares * (rebaseLimitPlus1 - pooledEtherRate)) / + rebaseLimitPlus1 + ); + return maxSharesToBurn; +} + + +/// @dev Summary of `Accounting._calculateTotalProtocolFeeShares` +function CVLcalculateTotalProtocolFeeShares( + Accounting.ReportValues report, + Accounting.CalculatedValues update, + uint256 internalSharesBeforeFees, + uint256 _totalFee, + uint256 _feePrecisionPoints +) returns uint256 { + mathint unifiedClBalanceValue = unifiedClBalance(report, update); + if (unifiedClBalanceValue <= update.principalClBalance) { + return 0; + } + mathint totalRewards = getTotalRewards(report, update); + mathint feeEther = (totalRewards * _totalFee) / _feePrecisionPoints; // See Accounting Line 260 + + require(update.postInternalEther > feeEther, "Avoid division by zero"); + mathint sharesToMintAsFees = ( + (feeEther * internalSharesBeforeFees) / (update.postInternalEther - feeEther) + ); + return require_uint256(sharesToMintAsFees); +} + +// ---- Utilities -------------------------------------------------------------- + +/// @dev The unified consensus layer balance, see `Accounting.sol` Line 279 +definition unifiedClBalance( + Accounting.ReportValues report, + Accounting.CalculatedValues update +) returns mathint = report.clBalance + update.withdrawalsVaultTransfer; + + +/// @dev Total rewards in this report, see `Accounting.sol` Line 285 +definition getTotalRewards( + Accounting.ReportValues report, + Accounting.CalculatedValues update +) returns mathint = ( + unifiedClBalance(report, update) - update.principalClBalance + update.elRewardsVaultTransfer +); + + +/// @dev Requires that the given address is not one of the contracts in the scene +function requireNotInScene(address a) { + require( + a != _Accounting && + a != _Burner && + a != _Lido && + a != _ELRewardsVault && + a != _WithdrawalVault && + a != _WithdrawalQueue, + "Require the address is not one of the main contracts in the scene" + ); +} + +// ---- Rules ------------------------------------------------------------------ + + +/// @title Rewards are shares minted as fees and `_Lido` balance increase +/// @notice Also see `feesAreFraction` in `Accounting-fees-as-frac.spec` for a bound on the fees. +rule feesMintShares(Accounting.ReportValues report) { + address treasury = _Accounting.treasury(); + // To prevent spurious counter-examples, require `treasury` is not one of the main + // contracts + requireNotInScene(treasury); + + env e; + Accounting.CalculatedValues update = _Accounting.simulateOracleReport(e, report); + mathint totalRewards = getTotalRewards(report, update); + require(totalRewards >= 0, "Assume non-negative rewards"); + + mathint feeEther = (totalRewards * totalFeeGhost) / FEE_PRECISION_POINTS(); + + uint256 balancePre = nativeBalances[_Lido]; + uint256 sharesPre = _Lido.getTotalShares(); + uint256 treasurySharesPre = _Lido.sharesOf(treasury); + + _Accounting.handleOracleReport(e, report); + + uint256 balancePost = nativeBalances[_Lido]; + uint256 sharesPost = _Lido.getTotalShares(); + uint256 treasurySharesPost = _Lido.sharesOf(treasury); + + mathint treasuryShareFees = treasurySharesPost - treasurySharesPre; + + assert(sharesPost - sharesPre <= update.sharesToMintAsFees, "Only fee shares are minted"); + assert(treasuryShareFees <= update.sharesToMintAsFees, "Fee shares owned by treasury"); +} + +// ---- Report revert rules ---------------------------------------------------- + +/// @title Verify that a deposit done after a report was computed but before it was applied +/// will not cause a revert +/// @dev This is not in CI because it times out. +rule reportNotRevertsByDeposit( + Accounting.ReportValues report, + uint256 _maxDepositsCount, + uint256 _stakingModuleId, + bytes _depositCalldata +) { + env e; + + // Enforce correct number of shares + mathint internalSharesPre = _Lido.getTotalShares() - _Lido.getExternalShares(); + require( + internalSharesPre >= _Lido.sharesOf(_Burner) + _Lido.sharesOf(_WithdrawalQueue), + "Correct total number of internal shares" + ); + + // Enforce reasonable report values + uint256 queueSharesToBurn; + (_, queueSharesToBurn) = _WithdrawalQueue.prefinalize( + e, report.withdrawalFinalizationBatches, 1 // Arbitrary number + ); + require( + queueSharesToBurn <= report.sharesRequestedToBurn, + "Shares to burn includes withdrawal queue shares to burn" + ); + + // Check the report without withdrawals + Accounting.CalculatedValues update = _Accounting.simulateOracleReport(e, report); + uint256 postInternalShares = update.postInternalShares; + uint256 postInternalEther = update.postInternalEther; + uint256 postTotalPooledEther = update.postTotalPooledEther; + uint256 postTotalShares = update.postTotalShares; + + require( + postTotalPooledEther <= max_uint128 && + postInternalEther <= max_uint128 && + postTotalShares <= max_uint128 && + postInternalShares <= max_uint128, + "Avoid overflows - Lido contract provides only uint128 for these" + ); + + storage initial = lastStorage; + _Accounting.handleOracleReport(e, report); // Ensure no revert + + // Perform action before report + env edeposit; + uint256 depositedValidators = _Lido.getDepositedValidators(); + require( + _maxDepositsCount + depositedValidators <= max_uint128, + "Prevent overflow of DepositedValidators" + ); + _Lido.deposit(edeposit, _maxDepositsCount, _stakingModuleId, _depositCalldata) at initial; + + _Accounting.handleOracleReport@withrevert(e, report); + assert(!lastReverted, "Actions since ref slot should not revert report handling"); +} + + +/// @title Verify that a `submit` done after a report was computed but before it was applied +/// will not cause a revert +/// @dev This is not in CI because it times out. +rule reportNotRevertsBySubmit( + Accounting.ReportValues report, address _referral, uint256 amount +) { + env e; + + // Enforce correct number of shares + mathint internalSharesPre = _Lido.getTotalShares() - _Lido.getExternalShares(); + require( + internalSharesPre >= _Lido.sharesOf(_Burner) + _Lido.sharesOf(_WithdrawalQueue), + "Correct total number of internal shares" + ); + + // Enforce reasonable report values + uint256 queueSharesToBurn; + (_, queueSharesToBurn) = _WithdrawalQueue.prefinalize( + e, report.withdrawalFinalizationBatches, 1 // Arbitrary number + ); + require( + queueSharesToBurn <= report.sharesRequestedToBurn, + "Shares to burn includes withdrawal queue shares to burn" + ); + + // Check the report without withdrawals + Accounting.CalculatedValues update = _Accounting.simulateOracleReport(e, report); + uint256 postInternalShares = update.postInternalShares; + uint256 postInternalEther = update.postInternalEther; + uint256 postTotalPooledEther = update.postTotalPooledEther; + uint256 postTotalShares = update.postTotalShares; + + require( + postTotalPooledEther <= max_uint128 && + postInternalEther <= max_uint128 && + postTotalShares <= max_uint128 && + postInternalShares <= max_uint128, + "Avoid overflows - Lido contract provides only uint128 for these" + ); + uint256 totalPooledEtherAtInit = _Lido.getTotalPooledEther(); + + storage initial = lastStorage; + _Accounting.handleOracleReport(e, report); // Ensure no revert + + // Perform action before report + env esubmit; + require(e.msg.value == amount, "Set the amount submitted"); + require( + e.msg.value + totalPooledEtherAtInit < max_uint128, + "Avoid overflows due to unreasonably large submits" + ); + _Lido.submit(esubmit, _referral) at initial; + + _Accounting.handleOracleReport@withrevert(e, report); + assert(!lastReverted, "Actions since ref slot should not revert report handling"); +} + + +// ---- Unit tests ------------------------------------------------------------- + +/// @title Some revert conditions for `handleOracleReport` +rule handleOracleReportRevertConditions(Accounting.ReportValues report) { + uint256 depositedValidators = _Lido.getDepositedValidators(); + uint256 clValidators; + (_, clValidators) = _Lido.getBalanceAndClValidators(); + + env e; + address accountingOracle = _LidoLocator.accountingOracle(e); + + _Accounting.handleOracleReport@withrevert(e, report); + bool reverted = lastReverted; + + assert( + (e.msg.sender != accountingOracle) => reverted, + "Only accounting oracle can call handleOracleReport" + ); + assert( + report.timestamp >= e.block.timestamp => reverted, + "Must revert if report too in present or future" + ); + assert( + report.clValidators < clValidators => reverted, + "Report validaors num must be at least current number" + ); + assert( + report.clValidators > depositedValidators => reverted, + "Deposited validators cannot be less than report's number" + ); +} +// - No increase in EL rewards => no fees (`Accounting.sol` Line 255) +// - Lido balance change is sum of rewards minus amount transferred to Withdrawal Queue diff --git a/certora/specs/core/Lido_and_VaultHub.spec b/certora/specs/core/Lido_and_VaultHub.spec new file mode 100644 index 0000000000..4b744b46e0 --- /dev/null +++ b/certora/specs/core/Lido_and_VaultHub.spec @@ -0,0 +1,246 @@ +/* `Lido` and `VaultHub` properties +*/ + +import "./comprehensive-setup.spec"; + +// -- Ghosts and hooks --------------------------------------------------------- + +// Sum of all `VaultHub.records[vault].liabilityShares` +persistent ghost mathint sumVaultsLiabilityShares { + init_state axiom sumVaultsLiabilityShares == 0; +} + +hook Sstore _VaultHub.vh_storage.records[KEY address vault].liabilityShares uint96 newShares (uint96 oldShares) { + sumVaultsLiabilityShares = sumVaultsLiabilityShares + newShares - oldShares; +} + +// -- Utility functions -------------------------------------------------------- + +/// @dev `Lido` functions that can only be called by `VaultHub` +definition onlyCalledByVaultHub(method f) returns bool = ( + f.contract == _Lido && ( + f.selector == sig:LidoHarness.mintExternalShares(address, uint256).selector || + f.selector == sig:LidoHarness.burnExternalShares(uint256).selector || + f.selector == sig:LidoHarness.rebalanceExternalEtherToInternal(uint256).selector + ) +); + + +/// @dev `Lido` functions that can only be called by `Accounting` +definition onlyCalledByAccounting(method f) returns bool = ( + f.contract == _Lido && ( + f.selector == sig:LidoHarness.internalizeExternalBadDebt(uint256).selector || + f.selector == sig:LidoHarness.collectRewardsAndProcessWithdrawals( + uint256, uint256, uint256, uint256, uint256, uint256, uint256, uint256 + ).selector || + f.selector == sig:LidoHarness.emitTokenRebase( + uint256, uint256, uint256, uint256, uint256, uint256, uint256, uint256, uint256 + ).selector || + f.selector == sig:LidoHarness.mintShares(address, uint256).selector || + f.selector == sig:LidoHarness.processClStateUpdate( + uint256, uint256, uint256, uint256 + ).selector + ) +); + + +/// @dev A `VaultHub` function that can only be called by `Accounting` +definition vaultHubOnlyCalledByAccounting(method f) returns bool = ( + f.contract == _VaultHub && + f.selector == sig:VaultHubHarness.decreaseInternalizedBadDebt(uint256).selector +); + +// -- Rules -------------------------------------------------------------------- + +/// @title Verifies the `Lido` functions that can only be called by `VaultHub` +rule verifyOnlyCalledByVaultHub(method f) filtered { + f -> onlyCalledByVaultHub(f) +} { + env e; + calldataarg args; + f(e, args); + assert(e.msg.sender == _VaultHub, "only VaultHub can call these functions"); +} + + +/// @title Verifies functions that can only be called by `Accounting` +rule verifyOnlyCalledByAccounting(method f) filtered { + f -> onlyCalledByAccounting(f) || vaultHubOnlyCalledByAccounting(f) +} { + env e; + calldataarg args; + f(e, args); + assert(e.msg.sender == _Accounting, "only Accounting can call these functions"); +} + + +/// @title Diconnected vault has no liability shares +invariant disconnectedVaultHasNoLiability(address vault) + !_VaultHub.isVaultConnected(vault) => _VaultHub.liabilityShares(vault) == 0 + filtered { + f -> f.contract == _VaultHub // `VaultHub` is sufficient for this invariant + } + { + preserved _VaultHub.applyVaultReport( + address _other, + uint256 _reportTimestamp, + uint256 _reportTotalValue, + int256 _reportInOutDelta, + uint256 _reportCumulativeLidoFees, + uint256 _reportLiabilityShares, + uint256 _reportMaxLiabilityShares, + uint256 _reportSlashingReserve + ) with (env e) { + // The following two requirements show the indexes of `vault` and `_other` + // are different if `vault != _other`. + requireInvariant vaultToIndexIsCorrect(vault); + requireInvariant vaultToIndexIsCorrect(_other); + + require( + isInitialized(), + "Assumes `initialize` is called immediately after constructor" + ); + requireInvariant vaultsArrayIsNeverEmpty(); + + requireInvariant disconnectedVaultIsNotPending(_other); + } + } + + +/// @dev The inequality stems from rounding errors, for example +/// `VaultHub._settleObligations` may reduce external shares by more than the liability +/// shares. +/// @notice We added a requirement to prevent `Lido`'s external shares from overflowing. +invariant externalSharesAtMostSumLiabilityShares() + getExternalShares() <= sumVaultsLiabilityShares + _VaultHub.badDebtToInternalize() + filtered { + f -> ( + !onlyCalledByVaultHub(f) && !vaultHubOnlyCalledByAccounting(f) && + ( + f.contract == _Lido => ( + // `internalizeExternalBadDebt` is only called by `Accounting` + f.selector != sig:LidoHarness.internalizeExternalBadDebt(uint256).selector && + // `finalizeUpgrade_v3` sets external shares to zero + // `transferToVault` is not supported (reverts) + f.selector != sig:LidoHarness.transferToVault(address).selector + ) + ) + ) + } + { + preserved _VaultHub.mintShares( + address _vault, + address _recipient, + uint256 _amountOfShares + ) with (env e) { + require( + getExternalShares() + _amountOfShares < 2^128, + "Prevent Lido's external shares from overflowing" + ); + } + + // Prevent having a disconnected vault with non-zero liability shares + preserved _VaultHub.connectVault(address _vault) with (env e) { + requireInvariant disconnectedVaultHasNoLiability(_vault); + } + + preserved _VaultHub.rebalance(address _vault, uint256 _shares) with (env e) { + require( + _shares * _Lido.getInternalEther() < max_uint256, + "Prevent Lido.getPooledEthBySharesRoundUp from overflowing" + ); + } + } + + +/// @title Example showing external shares can be strictly less than sum libility shares +/// plus internalized bad debt +rule strictlyTooManyLiabilityShares(method f, address vault) filtered { + f -> f.contract == _VaultHub && ( + f.selector == sig:VaultHubHarness.rebalance(address,uint256).selector || + f.selector == sig:VaultHubHarness.forceRebalance(address).selector + ) +} { + require( + getExternalShares() == sumVaultsLiabilityShares + _VaultHub.badDebtToInternalize(), + "Assume equality of external to liability shares, see externalSharesAtMostSumLiabilityShares" + ); + require(getExternalShares() <= 10000, "Make the example simpler"); + require(sumVaultsLiabilityShares > 0, "Assume there are some liability shares"); + require( + _VaultHub.liabilityShares(vault) <= sumVaultsLiabilityShares, + "Sum liability shares is less than a single vault's" + ); + + uint256 numeratorInEther = _Lido.getShareRateNumerator(); + uint256 denominatorInShares = _Lido.getShareRateDenominator(); + require(numeratorInEther < denominatorInShares, "Assume share is worth less than 1 wei"); + require( + 100 * numeratorInEther >= 90 * denominatorInShares, + "Shares to eth ratio is at least 90%" + ); + + env e; + if (f.selector == sig:VaultHubHarness.rebalance(address,uint256).selector) { + uint256 amount; + _VaultHub.rebalance(e, vault, amount); + } else { + _VaultHub.forceRebalance(e, vault); + } + + satisfy getExternalShares() < sumVaultsLiabilityShares + _VaultHub.badDebtToInternalize(); +} + +/// @title Showing external shares and liability shares increase together, and decrease +/// together when the shares ratio is 1 or more +rule externalSharesLiabilitySharesChangeTogether(method f) filtered { + f -> ( + !f.isView && !onlyCalledByVaultHub(f) && !vaultHubOnlyCalledByAccounting(f) && + ( + f.contract == _Lido => ( + // `internalizeExternalBadDebt` is only called by `Accounting` + f.selector != sig:LidoHarness.internalizeExternalBadDebt(uint256).selector && + // `finalizeUpgrade_v3` sets external shares to zero + // `transferToVault` is not supported (reverts) + f.selector != sig:LidoHarness.transferToVault(address).selector + ) + ) + ) +} { + mathint externalsPre = getExternalShares(); + mathint liabilitiesPre = sumVaultsLiabilityShares + _VaultHub.badDebtToInternalize(); + + env e; + require( + externalsPre <= 2^100 && e.msg.value <= 2^100, + "Assume reasonable values to avoid overflows" + ); + + if (f.selector == sig:VaultHubHarness.mintShares(address, address, uint256).selector) { + // Special handling to avoid overflows + address vault; + address recipient; + uint256 amountOfShares; + require(amountOfShares <= 2^100, "Assume reasonable value to avoid overflow"); + _VaultHub.mintShares(e, vault, recipient, amountOfShares); + } else if (f.selector == sig:VaultHubHarness.connectVault(address).selector) { + address vault; + requireInvariant disconnectedVaultHasNoLiability(vault); + _VaultHub.connectVault(e, vault); + } else { + calldataarg args; + f(e, args); + } + + mathint externalsPost = getExternalShares(); + mathint liabilitiesPost = sumVaultsLiabilityShares + _VaultHub.badDebtToInternalize(); + + assert( + externalsPre > externalsPost <=> liabilitiesPre > liabilitiesPost, + "external shares and liabilities total increase together" + ); + assert( + externalsPre < externalsPost <=> liabilitiesPre < liabilitiesPost, + "external shares and liabilities total decerase together" + ); +} diff --git a/certora/specs/core/comprehensive-setup.spec b/certora/specs/core/comprehensive-setup.spec new file mode 100644 index 0000000000..5bf7403af2 --- /dev/null +++ b/certora/specs/core/comprehensive-setup.spec @@ -0,0 +1,256 @@ +/* A comprehensive setup for `Lido`, `VaultHub` and `Accounting` + +Setup containing: +- `Accounting` +- `VaultHubHarness` +- `LidoHarness` +- `StakingVault` +*/ + +import "../vaults/vaults-array.spec"; // Also uses `VaultHubHarness` as `_VaultHub` + +using Accounting as _Accounting; +using LidoHarness as _Lido; +using LidoLocator as _LidoLocator; +using Burner as _Burner; +using LidoExecutionLayerRewardsVault as _ELRewardsVault; +using WithdrawalVault as _WithdrawalVault; + +methods { + // `LidoLocator` + function _.vaultHub() external => _VaultHub expect address; + function _.lido() external => _Lido expect address; + function _.accounting() external => _Accounting expect address; + function _.burner() external => _Burner expect address; + function _.withdrawalQueue() external => NONDET; + function _.withdrawalVault() external => _WithdrawalVault expect address; + function _.elRewardsVault() external => _ELRewardsVault expect address; + function _.depositSecurityModule() external => NONDET; + function _.stakingRouter() external => NONDET; + + // `LidoHarness` + function Lido._getLidoLocator() internal returns (address) => _LidoLocator; + function LidoHarness.getExternalShares() external returns (uint256) envfree; + function LidoHarness.getInternalEther() external returns (uint256) envfree; + function LidoHarness.getShareRateNumerator() external returns (uint256) envfree; + function LidoHarness.getShareRateDenominator() external returns (uint256) envfree; + function LidoHarness.eip712Domain() external returns ( + string, string, uint256, address + ) => CVLeip712Domain(); + + function LidoHarness.getSharesByPooledEth( + uint256 _ethAmount + ) external returns (uint256) => CVLgetSharesByPooledEth(_ethAmount); + function LidoHarness.getPooledEthBySharesRoundUp( + uint256 _sharesAmount + ) external returns (uint256) => CVLgetPooledEthBySharesRoundUp(_sharesAmount); + + // `IKernel` (`@aragon/os/contracts/kernel/IKernel.sol`) called by `AragonApp` + function _.hasPermission(address, address, bytes32, bytes) external => NONDET; + + // `ConversionHelpers` Lib (`node_modules/@aragon/os/contracts/common/ConversionHelpers.sol` + // called by `AragonApp` + // The summary below is not sound since we return a reference type, however it is + // only used as parameter for `hasPermission` above, which is summarized as `NONDET`. + function ConversionHelpers.dangerouslyCastUintArrayToBytes( + uint256[] memory + ) internal returns (bytes memory) => CVLNondetBytes(); + + // The following is a view function in `@aragon/os/contracts/kernel/Kernel.sol` + function _.getRecoveryVault() external => NONDET; + + // `Burner` + function _.commitSharesToBurn(uint256) external => DISPATCHER(true); + function _.requestBurnShares(address, uint256) external => DISPATCHER(true); + + // `IEIP712StETH` + function _.hashTypedDataV4(address, bytes32) external => NONDET; + + // `LazyOracle` + function _.latestReportTimestamp() external => NONDET; + + // `StakingVault` + // Without the following summary, the call from `VaultHub`:Line 1071, + // `_predepositGuarantee().proveUnknownValidator(_witness, IStakingVault(_vault))`, + // becomes unresolved ("callee contract unresolved"). + function _.withdraw(address, uint256) external => DISPATCHER(true); + + function _.beaconChainDepositsPaused() external => DISPATCHER(true); + function _.resumeBeaconChainDeposits() external => DISPATCHER(true); + function _.pauseBeaconChainDeposits() external => DISPATCHER(true); + function _.transferOwnership(address) external => DISPATCHER(true); + function _.pendingOwner() external => DISPATCHER(true); + function _.depositor() external => DISPATCHER(true); + function _.owner() external => DISPATCHER(true); + function _.nodeOperator() external => DISPATCHER(true); + function _.acceptOwnership() external => DISPATCHER(true); + function _.fund() external => DISPATCHER(true); + function _.requestValidatorExit(bytes) external => DISPATCHER(true); + function _.triggerValidatorWithdrawals(bytes, uint64[], address) external => DISPATCHER(true); + + // Summarize the call to `WITHDRAWAL_REQUEST` in `TriggerableWithdrawals` library + // as `NONDET`. NOTE: This is not sound but necessary for analysis. + unresolved external in StakingVault.triggerValidatorWithdrawals( + bytes, uint64[], address + ) => DISPATCH [] default NONDET; + + // `PredepositGuarantee` + // Without the following summary, the call from `VaultHub`:Line 929, + // `_predepositGuarantee().proveUnknownValidator(_witness, IStakingVault(_vault))`, + // becomes unresolved ("callee contract unresolved"). + function _.proveUnknownValidator( + IPredepositGuarantee.ValidatorWitness, address + ) external => DISPATCHER(true); + + // `BLS` Library + // Summarizing the `BLS` library since the Prover cannot easily handle such + // calculations and it contains many unsafe memory operations that hurt static + // analysis. Using NONDET as it's the most practical approach for verification. + function BLS12_381.verifyDepositMessage( + bytes calldata, + bytes calldata, + uint256, + BLS12_381.DepositY calldata, + bytes32, + bytes32 + ) internal => NONDET; + function BLS12_381.sha256Pair(bytes32, bytes32) internal returns (bytes32) => NONDET; + function BLS12_381.pubkeyRoot(bytes calldata) internal returns (bytes32) => NONDET; + + // `SSZ` Library + // NOTE: Summarized as NONDET due to complexity of SSZ operations + function SSZ.hashTreeRoot(SSZ.BeaconBlockHeader memory) internal returns (bytes32) => NONDET; + function SSZ.hashTreeRoot(SSZ.Validator memory) internal returns (bytes32) => NONDET; + function SSZ.verifyProof(bytes32[] calldata, bytes32, bytes32, SSZ.GIndex) internal => NONDET; + + // `CLProofVerifier` + // NOTE: Using wildcard and NONDET as the Prover cannot resolve CLProofVerifier + // (it worked in previous versions of the code `d1b4b34ebc911f01aca285d8d7b758f8c5fc7619`) + function _._validatePubKeyWCProof( + IPredepositGuarantee.ValidatorWitness calldata, + bytes32 + ) internal => NONDET; + + // `StakingRouter` (called by `Accounting`) + function _.getStakingRewardsDistribution() external => NONDET; + function _.getStakingModuleMaxDepositsCount(uint256, uint256) external => NONDET; + // NOTE: The summary of `reportRewardsMinted` is not sound - returns NONDET + function _.reportRewardsMinted(uint256[], uint256[]) external => NONDET; + // NOTE: The summary of `deposit` is not sound - returns NONDET + function _.deposit(uint256, uint256, bytes) external => NONDET; + + // `OracleReportSanityChecker` + function _.smoothenTokenRebase( + uint256, uint256, uint256, uint256, uint256, uint256, uint256, uint256, uint256 + ) external => NONDET; + function _.checkAccountingOracleReport( + uint256, uint256, uint256, uint256, uint256, uint256, uint256, uint256 + ) external => NONDET; + function _.checkWithdrawalQueueOracleReport(uint256, uint256) external => NONDET; + + // `WithdrawalQueueERC721` + function _.isPaused() external => NONDET; + function _.prefinalize(uint256[], uint256) external => NONDET; + function _.isBunkerModeActive() external => NONDET; + function _.unfinalizedStETH() external => NONDET; + // NOTE: The summary of `finalize` is not sound - returns NONDET + function _.finalize(uint256, uint256) external => NONDET; + + // `LidoExecutionLayerRewardsVault` + function _.withdrawRewards(uint256) external => DISPATCHER(true); + + // `WithdrawalVault` + function _.withdrawWithdrawals(uint256) external => DISPATCHER(true); + + // `IPostTokenRebaseReceiver` + // This interface has a single function. Its only implementation is in + // `test/0.4.24/contracts/PostTokenRebaseReceiver__MockForAccounting.sol` where it + // does nothing apart from emitting an event. + function _.handlePostTokenRebase( + uint256, uint256, uint256, uint256, uint256, uint256, uint256 + ) external => NONDET; +} + +// -- Summary functions -------------------------------------------------------- + +/// @dev Summarize the multiplication and division to reduce chances of timeout. +/// @notice While the original function will revert if `_ethAmount` exceeds `UINT128_MAX`, +/// this summary will not. +function CVLgetSharesByPooledEth(uint256 _ethAmount) returns uint256 { + uint256 numeratorInEther = _Lido.getShareRateNumerator(); + uint256 denominatorInShares = _Lido.getShareRateDenominator(); + + require( + numeratorInEther > 0, "Avoid division by zero in getSharesByPooledEth summary" + ); + require( + denominatorInShares < 2^128, + "Cannot be higher than 2^128 due to the way it is stored" + ); + + return require_uint256((_ethAmount * denominatorInShares) / numeratorInEther); +} + + +/// @dev Summarize the multiplication and division to reduce chances of timeout. +/// @notice While the original function will revert if `_sharesAmount` exceeds `UINT128_MAX`, +/// this summary will not. +function CVLgetPooledEthBySharesRoundUp(uint256 _sharesAmount) returns uint256 { + uint256 numeratorInEther = _Lido.getShareRateNumerator(); + uint256 denominatorInShares = _Lido.getShareRateDenominator(); + + require( + denominatorInShares > 0, + "Avoid division by zero in getPooledEthBySharesRoundUp summary" + ); + // NOTE: Lido has been notified about potential overflow risk + require( + numeratorInEther < 2^128, + "Prevent numeratorInEther * _shareAmount from overflowing in getPooledEthBySharesRoundUp" + ); + require( + denominatorInShares < 2^128, + "Cannot be higher than 2^128 due to the way it is stored" + ); + + return require_uint256( + // Add `denominatorInShares - 1` to round up + (_sharesAmount * numeratorInEther + denominatorInShares - 1) + / denominatorInShares + ); +} + + +/// @dev Summarize `Lido.eip712Domain` as non-deterministic +function CVLeip712Domain() returns (string, string, uint256, address) { + string name; + string version; + uint256 chainId; + address verifyingContract; + return (name, version, chainId, verifyingContract); +} + + +/// @dev A non-deterministic bytes array +function CVLNondetBytes() returns bytes { + bytes ret; + return ret; +} + +// ---- Rules: verifying summaries --------------------------------------------- + +/// @title Verifies summary of `getSharesByPooledEthSummary` +rule verifygetSharesByPooledEthSummary(uint256 _ethAmount) { + env e; + assert _Lido.getSharesByPooledEth(e, _ethAmount) == CVLgetSharesByPooledEth(_ethAmount); +} + + +/// @title Verifies summary of `getPooledEthBySharesRoundUp` +rule verifygetPooledEthBySharesRoundUp(uint256 _sharesAmount) { + env e; + assert ( + _Lido.getPooledEthBySharesRoundUp(e, _sharesAmount) == + CVLgetPooledEthBySharesRoundUp(_sharesAmount) + ); +} diff --git a/certora/specs/lido/Lido.spec b/certora/specs/lido/Lido.spec new file mode 100644 index 0000000000..d757498f9a --- /dev/null +++ b/certora/specs/lido/Lido.spec @@ -0,0 +1,603 @@ +/* Spec for Lido contract properties */ +using Accounting as _Accounting; +using LidoHarness as _Lido; +using LidoLocator as _LidoLocator; +using LidoExecutionLayerRewardsVault as _ELRewardsVault; +using WithdrawalVault as _WithdrawalVault; +using WithdrawalQueueERC721 as _WithdrawalQueue; + + +methods { + // `LidoLocator` + function _.lido() external => _Lido expect address; + function _.withdrawalQueue() external => _WithdrawalQueue expect address; + function _.withdrawalVault() external => _WithdrawalVault expect address; + function _.elRewardsVault() external => _ELRewardsVault expect address; + function _.depositSecurityModule() external => NONDET; + function _.stakingRouter() external => NONDET; + function _.accounting() external => _Accounting expect address; + function _.vaultHub() external => NONDET; + function _.burner() external => NONDET; + function _.accountingOracle() external => NONDET; + + // `LidoHarness` + function Lido._getLidoLocator() internal returns (address) => _LidoLocator; + function LidoHarness.getTotalShares() external returns (uint256) envfree; + function LidoHarness.getExternalShares() external returns (uint256) envfree; + function LidoHarness.getInternalEther() external returns (uint256) envfree; + function LidoHarness.getShareRateNumerator() external returns (uint256) envfree; + function LidoHarness.getShareRateDenominator() external returns (uint256) envfree; + function LidoHarness.getBufferedEther() external returns (uint256) envfree; + function LidoHarness.getDepositedValidators() external returns (uint256) envfree; + function LidoHarness.getPrevStakeLimit() external returns (uint96) envfree; + function LidoHarness.getPrevStakeBlockNumber() external returns (uint32) envfree; + function LidoHarness.getMaxStakeLimit() external returns (uint96) envfree; + function LidoHarness.getMaxStakeLimitGrowthBlocks() external returns (uint32) envfree; + function LidoHarness.eip712Domain() external returns ( + string, string, uint256, address + ) => CVLeip712Domain(); + + function LidoHarness.getSharesByPooledEth( + uint256 _ethAmount + ) external returns (uint256) => CVLgetSharesByPooledEth(_ethAmount); + function LidoHarness.getPooledEthBySharesRoundUp( + uint256 _sharesAmount + ) external returns (uint256) => CVLgetPooledEthBySharesRoundUp(_sharesAmount); + + // `IKernel` (`@aragon/os/contracts/kernel/IKernel.sol`) called by `AragonApp` + function _.hasPermission(address, address, bytes32, bytes) external => NONDET; + + // `VaultHub` + function _.badDebtToInternalizeAsOfLastRefSlot() external => NONDET; + function _.decreaseInternalizedBadDebt(uint256) external => NONDET; + + // `OracleReportSanityChecker` + function _.smoothenTokenRebase( + uint256, uint256, uint256, uint256, uint256, uint256, uint256, uint256, uint256 + ) external => DISPATCHER(true); + function _.checkAccountingOracleReport( + uint256, uint256, uint256, uint256, uint256, uint256, uint256, uint256 + ) external => DISPATCHER(true); + function _.checkWithdrawalQueueOracleReport(uint256, uint256) external => DISPATCHER(true); + + // `AccountingOracle` + function _.getLastProcessingRefSlot() external => NONDET; + + // `Burner` + // NOTE: These two summaries completely ignore and disable any side effects + function _.commitSharesToBurn(uint256) external => NONDET; + function _.requestBurnShares(address, uint256) external => NONDET; + function _.getSharesRequestedToBurn() external => NONDET; + + // `ConversionHelpers` Lib (`node_modules/@aragon/os/contracts/common/ConversionHelpers.sol` + // called by `AragonApp` + // The summary below is not sound since we return a reference type, however it is + // only used as parameter for `hasPermission` above, which is summarized as `NONDET`. + function ConversionHelpers.dangerouslyCastUintArrayToBytes( + uint256[] memory + ) internal returns (bytes memory) => CVLNondetBytes(); + + // The following is a view function in `@aragon/os/contracts/kernel/Kernel.sol` + function _.getRecoveryVault() external => NONDET; + + // `StakingRouter` (called by `Accounting`) + function _.getStakingRewardsDistribution() external => NONDET; + function _.getStakingModuleMaxDepositsCount(uint256, uint256) external => NONDET; + // NOTE: The summary of `reportRewardsMinted` is not sound - returns NONDET + function _.reportRewardsMinted(uint256[], uint256[]) external => NONDET; + // NOTE: The summary of `deposit` is not sound - returns NONDET + function _.deposit(uint256, uint256, bytes) external => NONDET; + function _.getStakingModuleIds() external => CVLNondetUint() expect (uint256[]); + function _.getStakingModule(uint256) external => NONDET; + + // `ISecondOpinionOracle` + function _.getReport(uint256) external => NONDET; + + // `WithdrawalQueueERC721` + function _.isPaused() external => NONDET; + function _.prefinalize(uint256[], uint256) external => NONDET; + function _.isBunkerModeActive() external => NONDET; + function _.unfinalizedStETH() external => NONDET; + // NOTE: The summary of `finalize` is not sound - returns NONDET + function _.finalize(uint256, uint256) external => NONDET; + + // `LidoExecutionLayerRewardsVault` + function _.withdrawRewards(uint256) external => DISPATCHER(true); + + // `WithdrawalVault` + function _.withdrawWithdrawals(uint256) external => DISPATCHER(true); + + // `WithdrawalQueue` + function _.getWithdrawalStatus(uint256[]) external => DISPATCHER(true); + + // `IPostTokenRebaseReceiver` + // This interface has a single function. Its only implementation is in + // `test/0.4.24/contracts/PostTokenRebaseReceiver__MockForAccounting.sol` where it + // does nothing apart from emitting an event. + function _.handlePostTokenRebase( + uint256, uint256, uint256, uint256, uint256, uint256, uint256 + ) external => NONDET; +} + +// -- Summary functions -------------------------------------------------------- + +/// @dev Summarize the multiplication and division to reduce chances of timeout. +/// @notice While the original function will revert if `_ethAmount` exceeds `UINT128_MAX`, +/// this summary will not. +function CVLgetSharesByPooledEth(uint256 _ethAmount) returns uint256 { + uint256 numeratorInEther = _Lido.getShareRateNumerator(); + uint256 denominatorInShares = _Lido.getShareRateDenominator(); + + require( + numeratorInEther > 0, "Avoid division by zero in getSharesByPooledEth summary" + ); + require( + denominatorInShares < 2^128, + "Cannot be higher than 2^128 due to the way it is stored" + ); + + return require_uint256((_ethAmount * denominatorInShares) / numeratorInEther); +} + + +/// @dev Summarize the multiplication and division to reduce chances of timeout. +/// @notice While the original function will revert if `_sharesAmount` exceeds `UINT128_MAX`, +/// this summary will not. +function CVLgetPooledEthBySharesRoundUp(uint256 _sharesAmount) returns uint256 { + uint256 numeratorInEther = _Lido.getShareRateNumerator(); + uint256 denominatorInShares = _Lido.getShareRateDenominator(); + + require( + denominatorInShares > 0, + "Avoid division by zero in getPooledEthBySharesRoundUp summary" + ); + require( + numeratorInEther < 2^128, + "Prevent numeratorInEther * _shareAmount from overflowing in getPooledEthBySharesRoundUp" + ); + require( + denominatorInShares < 2^128, + "Cannot be higher than 2^128 due to the way it is stored" + ); + + return require_uint256( + // Add `denominatorInShares - 1` to round up + (_sharesAmount * numeratorInEther + denominatorInShares - 1) + / denominatorInShares + ); +} + + +/// @dev Summarize `Lido.eip712Domain` as non-deterministic +function CVLeip712Domain() returns (string, string, uint256, address) { + string name; + string version; + uint256 chainId; + address verifyingContract; + return (name, version, chainId, verifyingContract); +} + + +/// @dev A non-deterministic bytes array +function CVLNondetBytes() returns bytes { + bytes ret; + return ret; +} + + +/// @dev A non-deterministic `uint256` array +function CVLNondetUint() returns uint256[] { + uint256[] ret; + return ret; +} + +// ---- Utility functions ------------------------------------------------------ + +definition isDepracatedFunc(method f) returns bool = ( + f.selector == sig:LidoHarness.transferToVault(address).selector +); + +/// @dev The `finalizeUpgrade_v3` function copies the old storage data into a new +/// unstructured storage, which makes it hard to verify +definition isUpgradeFunc(method f) returns bool = ( + f.selector == sig:LidoHarness.finalizeUpgrade_v3(address,address[],uint256).selector +); + +// ---- Rules: verifying summaries --------------------------------------------- + +/// @title Verifies summary of `getSharesByPooledEthSummary` +rule verifygetSharesByPooledEthSummary(uint256 _ethAmount) { + env e; + assert _Lido.getSharesByPooledEth(e, _ethAmount) == CVLgetSharesByPooledEth(_ethAmount); +} + + +/// @title Verifies summary of `getPooledEthBySharesRoundUp` +rule verifygetPooledEthBySharesRoundUp(uint256 _sharesAmount) { + env e; + assert ( + _Lido.getPooledEthBySharesRoundUp(e, _sharesAmount) == + CVLgetPooledEthBySharesRoundUp(_sharesAmount) + ); +} + +// ---- Utility rules ---------------------------------------------------------- + +/// @dev A method that can only be called by `Accounting` +definition isOnlyCalledByAccounting(method f) returns bool = ( + f.selector == sig:LidoHarness.collectRewardsAndProcessWithdrawals( + uint256, uint256, uint256, uint256, uint256, uint256, uint256, uint256 + ).selector +); + +rule canOnlyBeCalledByAccounting(method f) filtered { + f -> f.contract == _Lido && isOnlyCalledByAccounting(f) +} { + env e; + calldataarg args; + f(e, args); + assert(e.msg.sender == _Accounting, "Only called by Accounting"); +} + +// ---- Property: solvency ----------------------------------------------------- + +invariant bufferedEthBackedByBalance() + _Lido.getBufferedEther() <= nativeBalances[_Lido] + filtered { + // This function is disabled + f -> ( + !isOnlyCalledByAccounting(f) && // This will be verified via Accounting + !isDepracatedFunc(f) && + !isUpgradeFunc(f) + ) + } + { + preserved with (env e) { + require( + e.msg.value > 0 => (e.msg.sender != _Lido), + "Assume Lido does not transfer ETH to self" + ); + } + } + + +// ---- Variable transitions --------------------------------------------------- + +/// @dev Returns whether a method mints shares +definition isMintingFunc(method f) returns bool = ( + f.selector == sig:LidoHarness.mintExternalShares(address,uint256).selector || + f.selector == sig:LidoHarness.mintShares(address,uint256).selector +); + +definition maxReasonableValue() returns mathint = 2^100; + +/// @title Relations between total, external and internal shares +rule sharesTransition(method f) filtered { + f -> ( + !f.isView && + f.contract == _Lido && !isDepracatedFunc(f) && + // NOTE: Skipping version 3 upgrade as it's not relevant for current verification + f.selector != sig:LidoHarness.finalizeUpgrade_v3(address, address[],uint256).selector && + // Skipping initialization (assuming already initialized) + f.selector != sig:LidoHarness.initialize(address, address).selector + ) +} { + uint256 totalPre = _Lido.getTotalShares(); + uint256 extPre = _Lido.getExternalShares(); + uint256 buffPre = _Lido.getBufferedEther(); + + require( + totalPre <= maxReasonableValue() && + extPre <= maxReasonableValue() && + buffPre <= maxReasonableValue(), + "Assume reasonable values to avoid overflows" + ); + + env e; + require( + e.msg.value < maxReasonableValue() && + CVLgetSharesByPooledEth(e.msg.value) < maxReasonableValue(), + "Assume reasonable ETH transfer value to avoid overflows" + ); + + if (isMintingFunc(f)) { + address recipient; + uint256 amountOfShares; + require( + amountOfShares < maxReasonableValue(), + "Assume reasonable values to avoid overflows" + ); + if (f.selector == sig:LidoHarness.mintExternalShares(address,uint256).selector) { + _Lido.mintExternalShares(e, recipient, amountOfShares); + } else if (f.selector == sig:LidoHarness.mintShares(address,uint256).selector) { + _Lido.mintShares(e, recipient, amountOfShares); + } + } else { + calldataarg args; + f(e, args); + } + + uint256 totalPost = _Lido.getTotalShares(); + uint256 extPost = _Lido.getExternalShares(); + uint256 buffPost = _Lido.getBufferedEther(); + + assert( + extPost > extPre => totalPost - totalPre == extPost - extPre, + "Increase in external shares implies the same increase in total shares" + ); + assert( + (extPost == extPre && totalPost > totalPre) => ( + buffPost > buffPre || // Someone deposited ETH for shares + f.selector == sig:LidoHarness.mintShares(address, uint256).selector + ), + "When internal shares are minted" + ); + assert( + extPost < extPre => ( + (totalPost == totalPre && buffPost > buffPre) || + (totalPre - totalPost == extPre - extPost) || + ( + totalPost == totalPre && + buffPost == buffPre && + f.selector == sig:LidoHarness.internalizeExternalBadDebt(uint256).selector + ) + ), + "Either rebalanced externa or burned external shares or internalized bad debt" + ); + assert( + (totalPost < totalPre && extPost == extPre) => + f.selector == sig:LidoHarness.burnShares(uint256).selector, + "Only burning internal shares can reduce the total but not the external shares" + ); +} + +// ---- Staking limits --------------------------------------------------------- + +/// @dev Returns true of the function pauses staking +definition isPausingStakingFunc(method f) returns bool = ( + f.selector == sig:LidoHarness.pauseStaking().selector || + f.selector == sig:LidoHarness.stop().selector +); + +/// @title Previous staking block number is weakly monotonically increasing +rule prevStakingBlockNumberIncreasing(method f) filtered { + f -> !isDepracatedFunc(f) && !f.isView +} { + uint32 prevStakeBlockNumber = _Lido.getPrevStakeBlockNumber(); + + env e; + require( + e.block.number <= max_uint32, + "Assume reasonable number, avoid overflow when casting to uint32" + ); + calldataarg args; + f(e, args); + + uint32 curStakeBlockNumber = _Lido.getPrevStakeBlockNumber(); + + assert( + curStakeBlockNumber == prevStakeBlockNumber || + curStakeBlockNumber == e.block.number || + (curStakeBlockNumber == 0 && isPausingStakingFunc(f)), + "Value is either the current block number or previous value or zero and staking paused" + ); +} + + +/// @title Staking limits cannot change in the same function that stakes +rule stakingLimitsUnchangedIfStaking(method f) filtered { + f -> !isDepracatedFunc(f) && !f.isView +} { + uint256 internalEthPre = _Lido.getInternalEther(); + + uint96 maxStakeLimitPre = _Lido.getMaxStakeLimit(); + uint32 maxStakeLimitGrowthBlocksPre = _Lido.getMaxStakeLimitGrowthBlocks(); + + env e; + calldataarg args; + f(e, args); + + uint256 internalEthPost = _Lido.getInternalEther(); + + uint96 maxStakeLimitPost = _Lido.getMaxStakeLimit(); + uint32 maxStakeLimitGrowthBlocksPost = _Lido.getMaxStakeLimitGrowthBlocks(); + + assert( + internalEthPost != internalEthPre => ( + maxStakeLimitPre == maxStakeLimitPost && + maxStakeLimitGrowthBlocksPre == maxStakeLimitGrowthBlocksPost + ) + ); + assert( + ( + maxStakeLimitPre != maxStakeLimitPost || + maxStakeLimitGrowthBlocksPre != maxStakeLimitGrowthBlocksPost + ) => internalEthPost == internalEthPre + ); +} + + +definition internalShares() returns mathint = ( + _Lido.getTotalShares() - _Lido.getExternalShares() +); + +/// @title Internal ETH and shares increase cannot violate the staking limits +rule stakingLimitsAreKept(method f) filtered { + f -> ( + !isDepracatedFunc(f) && !f.isView && !isUpgradeFunc(f) && + // Initialize function can bypass the staking limits + f.selector != sig:LidoHarness.initialize(address,address).selector && + // rebalanceExternalEtherToInternal does not check for staking limit, it's an accepted behavior from Lido + f.selector != sig:LidoHarness.rebalanceExternalEtherToInternal(uint256).selector && + // resumeStaking is vacuous due to `require(isStakingPaused())` + f.selector != sig:LidoHarness.resumeStaking().selector && + // Ignore `Accounting.handleOracleReport` since it mints shares as fees + f.contract != _Accounting + ) +} { + env eInfo; // Needed since `e.msg.value` might not be zero + bool isStakingPaused; + bool isStakingLimitSet; + (isStakingPaused, isStakingLimitSet, _, _, _, _, _) = _Lido.getStakeLimitFullInfo(eInfo); + require(!isStakingPaused && isStakingLimitSet, "Assume staking is possible"); + + + uint96 prevStakeLimit = _Lido.getPrevStakeLimit(); + uint32 prevStakeBlockNumber = _Lido.getPrevStakeBlockNumber(); + uint96 maxStakeLimit = _Lido.getMaxStakeLimit(); + uint32 maxStakeLimitGrowthBlocks = _Lido.getMaxStakeLimitGrowthBlocks(); + + mathint bufferedEthPre = _Lido.getBufferedEther(); + mathint initernalSharesPre = internalShares(); + + env e; + require(e.block.number >= prevStakeBlockNumber, "Assume block numbers increase"); + require(e.block.number <= max_uint32,"Assume reasonable number, avoid overflow when casting to uint32"); + calldataarg args; + f(e, args); + + uint256 bufferedEthPost = _Lido.getBufferedEther(); + mathint initernalSharesPost = internalShares(); + + mathint ethDiff = bufferedEthPost - bufferedEthPre; + mathint sharesDiff = initernalSharesPost - initernalSharesPre; + mathint blockDiff = e.block.number - prevStakeBlockNumber; + + assert( + (sharesDiff > 0 && maxStakeLimitGrowthBlocks != 0) => ( + ethDiff <= + prevStakeLimit + (maxStakeLimit / maxStakeLimitGrowthBlocks) * blockDiff + ), + "Maximal staking per block must not be breached" + ); +} + +// ---- Variable transitions --------------------------------------------------- + +definition isIncreasingTotalShares(method f) returns bool = ( + f.selector == sig:LidoHarness.submit(address).selector || + f.selector == sig:LidoHarness.mintShares(address,uint256).selector || + f.selector == sig:LidoHarness.mintExternalShares(address,uint256).selector || + f.isFallback || + // The initialize function mints one share + f.selector == sig:LidoHarness.initialize(address,address).selector || + (f.contract == _Accounting && !f.isView) // Accounting mints fee shares +); + +definition isDecreasingTotalShares(method f) returns bool = ( + f.selector == sig:LidoHarness.burnShares(uint256).selector || + f.selector == sig:LidoHarness.burnExternalShares(uint256).selector || + (f.contract == _Accounting && !f.isView) // Accounting burns shares +); + +/// @title Determines the functions that can increase or decrease the total shares +rule totalSharesCanOnlyBeChangedBy(method f) filtered { + f -> !isDepracatedFunc(f) && !f.isView && !isUpgradeFunc(f) + +} { + uint256 sharesPre = _Lido.getTotalShares(); + + env e; + require( + sharesPre <= 2^100 && + _Lido.getExternalShares() < 2^100 && + CVLgetSharesByPooledEth(e.msg.value) <= 2^100, + "Prevent overflow of shares" + ); + if (f.selector == sig:LidoHarness.mintShares(address,uint256).selector) { + // Special handling to avoid overflow + address _recipient; + uint256 _amountOfShares; + require(_amountOfShares < 100, "Prevent overflow of shares"); + _Lido.mintShares(e, _recipient, _amountOfShares); + } else if (f.selector == sig:LidoHarness.mintExternalShares(address,uint256).selector) { + // Special handling to avoid overflow + address _recipient; + uint256 _amountOfShares; + require(_amountOfShares < 100, "Prevent overflow of shares"); + _Lido.mintExternalShares(e, _recipient, _amountOfShares); + } else { + calldataarg args; + f(e, args); + } + + uint256 sharesPost = _Lido.getTotalShares(); + assert(sharesPost > sharesPre => isIncreasingTotalShares(f)); + assert(sharesPost < sharesPre => isDecreasingTotalShares(f)); +} + + +definition isChangingBufferedEth(method f) returns bool = ( + f.selector == sig:LidoHarness.deposit(uint256,uint256,bytes).selector || + f.selector == sig:LidoHarness.submit(address).selector || + f.selector == sig:LidoHarness.mintShares(address,uint256).selector || + f.selector == sig:LidoHarness.rebalanceExternalEtherToInternal(uint256).selector || + f.selector == sig:LidoHarness.collectRewardsAndProcessWithdrawals( + uint256, uint256, uint256, uint256, uint256, uint256, uint256, uint256 + ).selector || + f.selector == sig:LidoHarness.initialize(address,address).selector || + f.isFallback || + (f.contract == _Accounting && !f.isView) +); + +/// @title Determines the functions that can change the buffered ETH +rule bufferedEthCanOnlyBeChangedBy(method f) filtered { + f -> !isDepracatedFunc(f) && !f.isView && !isUpgradeFunc(f) + +} { + uint256 bufferedEthPre = _Lido.getBufferedEther(); + + env e; + calldataarg args; + f(e, args); + + uint256 bufferedEthPost = _Lido.getBufferedEther(); + assert(bufferedEthPre != bufferedEthPost => isChangingBufferedEth(f)); +} + + +definition isChangingDepositedValidators(method f) returns bool = ( + f.selector == sig:LidoHarness.deposit(uint256,uint256,bytes).selector || + f.selector == sig:LidoHarness.unsafeChangeDepositedValidators(uint256).selector +); + + +rule depositedValidatorsOnlyIncreasing(method f) filtered { + f -> ( + !isDepracatedFunc(f) && !f.isView && + // `finalizeUpgrade_v3` initializes the `depositedValidators` + f.selector != sig:LidoHarness.finalizeUpgrade_v3(address,address[],uint256).selector && + // This method can change the number to anything + f.selector != sig:LidoHarness.unsafeChangeDepositedValidators(uint256).selector + ) + +} { + uint256 validatorsPre = _Lido.getDepositedValidators(); + uint256 bufferedEthPre = _Lido.getBufferedEther(); + + env e; + if (f.selector == sig:LidoHarness.deposit(uint256,uint256,bytes).selector) { + uint256 _maxDepositsCount; + uint256 _stakingModuleId; + bytes _depositCalldata; + require( + validatorsPre + _maxDepositsCount <= max_uint128, "Avoid overflow" + ); + _Lido.deposit(e, _maxDepositsCount, _stakingModuleId, _depositCalldata); + } else { + calldataarg args; + f(e, args); + } + + uint256 validatorsPost = _Lido.getDepositedValidators(); + uint256 bufferedEthPost = _Lido.getBufferedEther(); + assert(validatorsPost >= validatorsPre, "deposited validators only increase"); + assert( + validatorsPost > validatorsPre => ( + bufferedEthPost < bufferedEthPre && isChangingDepositedValidators(f) + ), + "adding validators by depositing ETH" + ); +} + +// ---- Changing internal ETH -------------------------------------------------- diff --git a/certora/specs/misc/burner.spec b/certora/specs/misc/burner.spec new file mode 100644 index 0000000000..56b2cf50f2 --- /dev/null +++ b/certora/specs/misc/burner.spec @@ -0,0 +1,176 @@ +/* Spec for the `Burner` contract */ + +import "../common/lido-storage-ghost.spec"; +import "../common/lido-summaries.spec"; + +using BurnerHarness as _Burner; +using LidoLocator as _LidoLocator; + +methods { + // `BurnerHarness` + function BurnerHarness.getExcessStETHShares() external returns (uint256) envfree; + + // `LidoLocator` + function _.burner() external => _Burner expect address; + function _.lido() external => _Lido expect address; + + function LidoLocator.treasury() external returns (address) envfree; +} + +// ---- Functions -------------------------------------------------------------- + +definition isValidBurnerFunc(method f) returns bool = ( + // It's pointless to call `Lido.approve` + f.contract == _Burner && + // The following two functions call third party contract via `safeTransfer` + f.selector != sig:BurnerHarness.recoverERC20(address,uint256).selector && + f.selector != sig:BurnerHarness.recoverERC721(address,uint256).selector +); + + +// ---- Rules ------------------------------------------------------------------ + +/// @title The `Burner` contract gives no allowance to any address +/// @notice This is part of proving that `Burner` shares are never transferred, only burned +invariant burnerDoesNotApprove(address a) + _Lido.allowance(_Burner, a) == 0 + filtered {f -> isValidBurnerFunc(f)} + { + preserved constructor() with (env e) { + // Before Burner is deployed, Lido cannot have an allowance + // for the Burner address (it doesn't exist yet) + require _Lido.allowance(_Burner, a) == 0; + } + } + + + +/// @title `Burner` shares can only be reduced by burning (excluding excess shares) +/// @notice This is part of proving that `Burner` shares are never transferred, only burned +rule burnerSharesOnlyBurnt(method f) filtered {f -> isValidBurnerFunc(f)} { + uint256 sharesPre = _Lido.sharesOf(_Burner); + uint256 totalPre = _Lido.getTotalShares(); + uint256 excessPre = _Burner.getExcessStETHShares(); + + env e; + calldataarg args; + f(e, args); + + uint256 sharesPost = _Lido.sharesOf(_Burner); + uint256 totalPost = _Lido.getTotalShares(); + + mathint sharesDiff = sharesPost - sharesPre; + mathint totalDiff = totalPost - totalPre; + + assert( + sharesPost < sharesPre => ( + (sharesDiff == totalDiff) // In case of burning + || ( + // In case of `recoverExcessStETH` + totalDiff == 0 && + sharesDiff <= excessPre && + f.selector == sig:BurnerHarness.recoverExcessStETH().selector + ) + ), + "Burner shares reduced only by burning" + ); +} + + +/// @title Burner does not affect unrelated parties shares +/// @notice For `requestBurnShares` and `requestBurnSharesForCover` when +/// shares rate is less than 1, due to `Lido.transferSharesFrom` not verifying that the +/// value of the shares is non-zero. +/// This is a KNOWN ISSUE requiring catastrophic conditions (>14% slashing) and is not +/// economically exploitable. Acknowledged by Lido team for future fix. +/// see issue `https://github.com/lidofinance/core/issues/1399` +/// and its duplicate issue `https://github.com/lidofinance/core/issues/796`. +/// We therefore assume that the share rate is at least 1. +rule burnerDoesNotAffectThirdPartyShares(method f, address anyone) filtered { + f -> isValidBurnerFunc(f) +} { + uint256 sharesPre = _Lido.sharesOf(anyone); + uint256 allowancePre = _Lido.allowance(anyone, _Burner); + + require(CVLgetPooledEthByShares(sharesPre) >= sharesPre, "Assume 1 share is more than 1 ETH"); + + env e; + calldataarg args; + f(e, args); + + uint256 sharesPost = _Lido.sharesOf(anyone); + + assert( + sharesPre != sharesPost => ( + anyone == _Burner || + e.msg.sender == anyone || + anyone == _LidoLocator.treasury() || + allowancePre > 0 + ), + "Burner does not affect unrelated parties shares" + ); +} + + +/// @title Integrity of request burn methods +rule burnRequestsIntegrity(method f, uint256 amount, address from, address thirdParty) filtered { + f -> ( + f.selector == sig:BurnerHarness.requestBurnMyStETHForCover(uint256).selector || + f.selector == sig:BurnerHarness.requestBurnSharesForCover(address,uint256).selector || + f.selector == sig:BurnerHarness.requestBurnMyShares(uint256).selector || + f.selector == sig:BurnerHarness.requestBurnMyStETH(uint256).selector || + f.selector == sig:BurnerHarness.requestBurnShares(address,uint256).selector + ) +} { + require(thirdParty != from && thirdParty != _Burner, "Unrelated third party"); + require(from != _Burner, "Burner does not request burns"); + + uint256 fromPre = _Lido.sharesOf(from); + uint256 burnerPre = _Lido.sharesOf(_Burner); + uint256 thirdPre = _Lido.sharesOf(thirdParty); + + env e; + if (f.selector == sig:BurnerHarness.requestBurnMyStETHForCover(uint256).selector) { + require(e.msg.sender == from, "Correct from address"); + require(CVLgetSharesByPooledEth(amount) > 0, "Nontrivial shares amount to burn"); + _Burner.requestBurnMyStETHForCover(e, amount); + } else if (f.selector == sig:BurnerHarness.requestBurnMyShares(uint256).selector) { + require(e.msg.sender == from, "Correct from address"); + _Burner.requestBurnMyShares(e, amount); + } else if (f.selector == sig:BurnerHarness.requestBurnMyStETH(uint256).selector) { + require(e.msg.sender == from, "Correct from address"); + require(CVLgetSharesByPooledEth(amount) > 0, "Nontrivial shares amount to burn"); + _Burner.requestBurnMyStETH(e, amount); + } else if (f.selector == sig:BurnerHarness.requestBurnSharesForCover(address,uint256).selector) { + _Burner.requestBurnSharesForCover(e, from, amount); + } else { + _Burner.requestBurnShares(e, from, amount); + } + + uint256 fromPost = _Lido.sharesOf(from); + uint256 burnerPost = _Lido.sharesOf(_Burner); + uint256 thirdPost = _Lido.sharesOf(thirdParty); + + assert(fromPre > fromPost, "From address reduced shares by burning request"); + assert(burnerPost > burnerPre, "Burner increased shares by burning request"); + assert(thirdPost == thirdPre, "Third party unaffected by burning request"); +} + + +/// @title Intergrity of `commitSharesToBurn` +rule commitBurnIntergrity(uint256 sharesToBurn) { + uint256 totalSharesPre = _Lido.getTotalShares(); + uint256 burnerPre = _Lido.sharesOf(_Burner); + + env e; + _Burner.commitSharesToBurn(e, sharesToBurn); + + uint256 totalSharesPost = _Lido.getTotalShares(); + uint256 burnerPost = _Lido.sharesOf(_Burner); + + assert( + totalSharesPre - totalSharesPost == sharesToBurn, + "Correct amount of shares burnt" + ); + assert(burnerPre - burnerPost == sharesToBurn, "Shares burnt only from Burner"); +} diff --git a/certora/specs/misc/node_operators.spec b/certora/specs/misc/node_operators.spec new file mode 100644 index 0000000000..e7703de94c --- /dev/null +++ b/certora/specs/misc/node_operators.spec @@ -0,0 +1,65 @@ +/* Spec for `NodeOperatorsRegistry` */ + +methods { + function getNodeOperatorsCount() external returns (uint256) envfree; + + // `IKernel` called by `AragonApp` + function _.hasPermission(address, address, bytes32, bytes) external => NONDET; + + // `ConversionHelpers` Lib (`node_modules/@aragon/os/contracts/common/ConversionHelpers.sol` + // called by `AragonApp` + // The summary below is not sound since we return a reference type, however it is + // only used as parameter for `hasPermission` above, which is summarized as `NONDET`. + function ConversionHelpers.dangerouslyCastUintArrayToBytes( + uint256[] memory + ) internal returns (bytes memory) => CVLNondetBytes(); + + // `SigningKeys` library - summarized to avoid pointer analysis failures + function SigningKeys.initKeysSigsBuf(uint256 _count) internal returns ( + bytes memory, bytes memory + ) => CVLNondetTwoBytes(_count); + + function SigningKeys.loadKeysSigs( + bytes32, uint256, uint256, uint256, bytes memory, bytes memory, uint256 + ) internal => NONDET; +} + + +/// @dev A non-deterministic bytes array +function CVLNondetBytes() returns bytes { + bytes ret; + return ret; +} + + +/// @dev This is `PUBKEY_LENGTH` defined is `contracts/0.4.24/lib/SigningKeys.sol` +definition PUBKEY_LENGTH() returns uint64 = 48; + +/// @dev Two non-deterministic bytes arrays +function CVLNondetTwoBytes(uint256 _count) returns (bytes, bytes) { + mathint len = _count * PUBKEY_LENGTH(); + bytes arr1; + bytes arr2; + require(arr1.length == len && arr2.length == len, "Correct length requirement"); + return (arr1, arr2); +} + + +definition isSupported(method f) returns bool = ( + f.selector != sig:transferToVault(address).selector && // Unsupported, reverts + f.selector != sig:obtainDepositData(uint256,bytes).selector // Prover cannot resolve MinFirstAllocationStrategy library call +); + + +/// @title The number of node operators is weakly monotonic increasing +rule operatorsCountIsIncreasing(method f) filtered {f -> isSupported(f)} { + uint256 numBefore = getNodeOperatorsCount(); + + env e; + calldataarg args; + f(e, args); + + uint256 numAfter = getNodeOperatorsCount(); + assert(numAfter >= numBefore, "Number of operators does not decrease"); + assert(numAfter <= numBefore + 1, "Number of operators increases at most by 1"); +} diff --git a/certora/specs/old-Lido.spec b/certora/specs/old-Lido.spec new file mode 100644 index 0000000000..91ec328a39 --- /dev/null +++ b/certora/specs/old-Lido.spec @@ -0,0 +1,369 @@ +import "setup/snippet_memutils.spec"; + +using Accounting as Accounting; +using NativeTransferFuncs as NTF; +using EIP712StETH as EIP712StETH; +using Kernel as Kernel; +using LidoLocator as LidoLocator; +using StakingRouter as StakingRouter; +using WithdrawalQueueERC721 as WithdrawalQueue; + +/************************************************** +* Methods * +**************************************************/ +methods{ + function initialize(address, address) external; + function finalizeUpgrade_v2(address, address) external; + function pauseStaking() external; + function resumeStaking() external; + function setStakingLimit(uint256, uint256) external; + function removeStakingLimit() external; + function isStakingPaused() external returns (bool) envfree; + function getCurrentStakeLimit() external returns (uint256); + function getStakeLimitFullInfo() external returns (bool, bool, uint256, uint256, uint256, uint256, uint256); // envfree + function submit(address) external returns (uint256); //payable + function receiveELRewards() external; //payable + function receiveWithdrawals() external; //payable + function deposit(uint256, uint256, bytes) external; + function stop() external; + function resume() external; + // handle oracle report + function unsafeChangeDepositedValidators(uint256) external; + function handleOracleReport(uint256, uint256) external; + function transferToVault(address) external; + function getFee() external returns (uint16) envfree; + function getFeeDistribution() external returns (uint16, uint16, uint16) envfree; + function getWithdrawalCredentials() external returns (bytes32) envfree; + function getBufferedEther() external returns (uint256) envfree; + function getTotalELRewardsCollected() external returns (uint256) envfree; + function getTreasury() external returns (address) envfree; + function getBeaconStat() external returns (uint256, uint256, uint256) envfree; + function canDeposit() external returns (bool) envfree; + function getDepositableEther() external returns (uint256) envfree; + function permit(address,address,uint256,uint256,uint8,bytes32,bytes32) external; + + // StEth: + function getTotalPooledEther() external returns (uint256) envfree; + function getTotalShares() external returns (uint256) envfree; + function sharesOf(address) external returns (uint256) envfree; + function getSharesByPooledEth(uint256) external returns (uint256) envfree; + function getPooledEthByShares(uint256) external returns (uint256) envfree; + function transferShares(address, uint256) external returns (uint256); + function transferSharesFrom(address, address, uint256) external returns (uint256); + + // function getRatio() external returns(uint256) envfree; + // function getCLbalance() external returns(uint256) envfree; + //function _.smoothenTokenRebase(uint256, uint256, uint256, uint256, uint256, uint256, uint256) external => DISPATCHER(true); + //function _.getSharesRequestedToBurn() external => DISPATCHER(true); + //function _.checkAccountingOracleReport(uint256, uint256, uint256, uint256, uint256, uint256, uint256) external => DISPATCHER(true); + + function LidoLocator.depositSecurityModule() external returns address envfree; + function LidoLocator.treasury() external returns address envfree; + + // Harness: + function stakingModuleMaxDepositsCount(uint256, uint256) external returns (uint256) envfree; + function LidoEthBalance() external returns (uint256) envfree; + function getEthBalance(address) external returns (uint256) envfree; + function collectRewardsAndProcessWithdrawals(uint256, uint256, uint256, uint256, uint256) external; + + // Summarizations: + + function Lido._stakingRouter() internal returns (address) => StakingRouter; + function Lido._withdrawalQueue() internal returns (address) => WithdrawalQueue; + function Lido._getLidoLocator() internal returns (address) => LidoLocator; + function LidoHarness.kernel() internal returns (address) => Kernel; + + function _.getStakingModuleSummary() external => CONSTANT; + function _.obtainDepositData(uint256,bytes) external => NONDET; + function Kernel.hasPermission(address,address,bytes32,bytes) external returns (bool) => NONDET; + function EVMScriptRunner.runScript(bytes memory,bytes memory,address[] memory) internal returns (bytes memory) => CVL_NONDET_bytes(); + function LidoHarness.getEVMScriptExecutor(bytes memory) internal returns (address) => NONDET; + function LidoHarness.getEVMScriptRegistry() internal returns (address) => NONDET; + + // initialize checks for getEIP712StETH() == 0, slashing with first line. + // thus, we summarize this check in the second line. + function LidoHarness.getEIP712StETH() internal returns (address) => EIP712StETH; + function StETHPermit._initializeEIP712StETH(address) internal => NONDET; + + // nativeTransferFuncs: + function NTF.withdrawRewards(uint256) external returns (uint256); + function NTF.withdrawWithdrawals(uint256) external; + + function _.withdrawRewards(uint256) external => DISPATCHER(true); + function _.withdrawWithdrawals(uint256) external => DISPATCHER(true); + + function _.finalize(uint256, uint256) external => DISPATCHER(true); + + function _.isValidSignature(address, bytes32, uint8, bytes32, bytes32) internal => NONDET; + + // burner + function _.getCoverSharesBurnt() external => DISPATCHER(true); + function _.getNonCoverSharesBurnt() external => DISPATCHER(true); + function _.getSharesRequestedToBurn() external => DISPATCHER(true); + + function _.handlePostTokenRebase(uint256, uint256, uint256, uint256, uint256, uint256, uint256) external => NONDET; + function _.onRewardsMinted(uint256) external => NONDET; +} + +function CVL_NONDET_bytes() returns (bytes) { + bytes ret; + return ret; +} + +/************************************************** +* Ghosts summaries * +**************************************************/ + +ghost ghostBurner() returns address { + axiom ghostBurner() != currentContract; + axiom ghostBurner() != 0; +} + +ghost ghostLegacyOracle() returns address { + axiom ghostLegacyOracle() != currentContract; + axiom ghostLegacyOracle() != 0; +} + +ghost ghostEIP712StETH() returns address; + +ghost ghostWithdrawalCredentials() returns bytes32; + +ghost ghostTotalFeeE4Precision() returns uint16 { + axiom to_mathint(ghostTotalFeeE4Precision()) <= 10000; +} + +ghost getAppGhost(bytes32, bytes32) returns address { + axiom forall bytes32 a . forall bytes32 b . + getAppGhost(a, b) != 0 && + getAppGhost(a, b) != currentContract; +} + +ghost ghostHashTypedDataV4(address, bytes32) returns bytes32 { + axiom forall address steth. forall bytes32 a .forall bytes32 b . + a != b => + ghostHashTypedDataV4(steth, a) != ghostHashTypedDataV4(steth, b); +} + +ghost MaxDepositsCount(uint256, uint256) returns uint256 { + axiom forall uint256 ID. forall uint256 maxValue. + to_mathint(MaxDepositsCount(ID, maxValue)) <= (maxValue / DEPOSIT_SIZE()); +} + +ghost uint256 ghostUnfinalizedStETH; + +function UnfinalizedStETH() returns uint256 { + /// Needs to be havoc'd after some call (figure out when and how) + return ghostUnfinalizedStETH; +} + +ghost bool WQPaused; + +function isWithdrawalQueuePaused() returns bool { + return WQPaused; +} + +/************************************************** +* CVL Helpers * +**************************************************/ +/** +To avoid overflow +**/ +function SumOfETHBalancesLEMax(address someUser) returns bool { + mathint s = + LidoEthBalance() + + getTotalELRewardsCollected() + + getTotalPooledEther() + + getEthBalance(StakingRouter) + + getEthBalance(WithdrawalQueue) + + getEthBalance(LidoLocator.treasury()) + + getEthBalance(LidoLocator.depositSecurityModule()) + + getEthBalance(someUser); + return s <= to_mathint(Uint128()); +} + +/** +To avoid overflow +**/ +function SumOfSharesLEMax(address someUser) returns bool { + mathint s = + sharesOf(currentContract) + + sharesOf(StakingRouter) + + sharesOf(WithdrawalQueue) + + sharesOf(LidoLocator.treasury()) + + sharesOf(LidoLocator.depositSecurityModule()) + + sharesOf(someUser); + return s <= to_mathint(Uint128()); +} + +/** +To avoid overflow +**/ +function ReasonableAmountOfShares() returns bool { + return getTotalShares() < Uint128() && getTotalPooledEther() < Uint128(); +} + +/************************************************** +* Definitions * +**************************************************/ +definition DEPOSIT_SIZE() returns uint256 = 32000000000000000000; +definition Uint128() returns uint256 = (1 << 128); + +definition isSubmit(method f) returns bool = + f.selector == sig:submit(address).selector; + +definition handleReportStepsMethods(method f) returns bool = + f.selector == sig:collectRewardsAndProcessWithdrawals(uint256,uint256,uint256,uint256,uint256,uint256,uint256,uint256).selector || + f.selector == sig:processClStateUpdate(uint256,uint256,uint256,uint256).selector; + +/************************************************** +* Rules * +**************************************************/ +invariant BufferedEthIsAtMostLidoBalance() + getBufferedEther() <= LidoEthBalance() + filtered{ f -> + f.selector != sig:permit(address,address,uint256,uint256,uint8,bytes32,bytes32).selector && + f.selector != sig:transferToVault(address).selector + } + { + preserved with (env e) { + require e.msg.sender != currentContract; + require SumOfETHBalancesLEMax(e.msg.sender); + require ReasonableAmountOfShares(); + } + } + +// /// Fails due to overflows. +// /// Need to come up with a condition on the total shares to prevent the overflows cases. +// rule getSharesByPooledEthDoesntRevert(uint256 amount, method f) +// filtered{f -> !f.isView } { +// env e; +// calldataarg args; +// require SumOfETHBalancesLEMax(e.msg.sender); +// require SumOfSharesLEMax(e.msg.sender); +// require ReasonableAmountOfShares(); +// require amount < Uint128(); + +// getSharesByPooledEth(amount); +// f(e, args); +// getSharesByPooledEth@withrevert(amount); + +// assert !lastReverted; +// } + +// rule submitCannotDoSFunctions(method f) +// filtered{f -> !(handleReportStepsMethods(f) || isSubmit(f))} { +// env e1; +// env e2; +// require e2.msg.sender != currentContract; +// calldataarg args; +// address referral; +// uint256 amount; + +// storage initState = lastStorage; +// require SumOfETHBalancesLEMax(e2.msg.sender); +// require ReasonableAmountOfShares(); + +// f(e1, args); + +// submit(e2, referral) at initState; + +// f@withrevert(e1, args); + +// assert !lastReverted; +// } + +/** +After calling submit: + 1. If there is a stake limit then it must decrease by the submitted eth amount. + 2. The user gets the expected amount of shares. + 3. Total shares is increased as expected. +**/ +rule integrityOfSubmit(address _referral) { + env e; + env e2; // to avoid vacuity due to payable / non-payable methods + require(e.msg.sender == e2.msg.sender); + require(e.block == e2.block); + require(0 < e.block.number && e.block.number < 2^32); // staking is paused when preBlockNumber is zero + uint256 ethToSubmit = e.msg.value; + uint256 old_stakeLimit = getCurrentStakeLimit(e2); + uint256 expectedShares = getSharesByPooledEth(ethToSubmit); + + uint256 shareAmount = submit(e, _referral); + + uint256 new_stakeLimit = getCurrentStakeLimit(e2); + + assert (old_stakeLimit < max_uint256) => (new_stakeLimit == assert_uint256(old_stakeLimit - ethToSubmit)); + assert expectedShares == shareAmount; +} + +/** +After a successful call for deposit: + 1. Bunker mode is inactive and the protocol is not stopped + 2. If any of max deposits is greater than zero then the buffered ETH must decrease. + 3. The buffered ETH must not increase. +**/ +rule integrityOfDeposit(uint256 _maxDepositsCount, uint256 _stakingModuleId, bytes _depositCalldata) { + env e; + + bool canDeposit = canDeposit(); + uint256 stakeLimit = getCurrentStakeLimit(e); + uint256 bufferedEthBefore = getBufferedEther(); + + uint256 maxDepositsCountSR = stakingModuleMaxDepositsCount(_stakingModuleId, getDepositableEther()); + + deposit(e, _maxDepositsCount, _stakingModuleId, _depositCalldata); + + uint256 bufferedEthAfter = getBufferedEther(); + + assert canDeposit; + assert (_maxDepositsCount > 0 && maxDepositsCountSR > 0) => bufferedEthBefore > bufferedEthAfter; + assert assert_uint256(bufferedEthBefore - bufferedEthAfter) <= bufferedEthBefore; +} + +/** +After a successful call for collectRewardsAndProcessWithdrawals: + 1. TOTAL_EL_REWARDS_COLLECTED_POSITION increase by + 2. contracts ETH balance must increase by elRewardsToWithdraw + withdrawalsToWithdraw - etherToLockOnWithdrawalQueue + 3. The buffered ETH must increase elRewardsToWithdraw + withdrawalsToWithdraw - etherToLockOnWithdrawalQueue +**/ +rule integrityOfCollectRewardsAndProcessWithdrawals(uint256 withdrawalsToWithdraw, uint256 elRewardsToWithdraw, uint256 withdrawalFinalizationBatch, uint256 simulatedShareRate, uint256 etherToLockOnWithdrawalQueue) { + env e; + require SumOfETHBalancesLEMax(e.msg.sender); + require ReasonableAmountOfShares(); + + uint256 contractEthBalanceBefore = LidoEthBalance(); + uint256 totalElRewardsBefore = getTotalELRewardsCollected(); + uint256 bufferedEthBefore = getBufferedEther(); + + Accounting.ReportValues report; + //require(contractEthBalanceBefore == 2); + //require(report.withdrawalVaultBalance == 0); + //require(report.elRewardsVaultBalance == 0); + + Accounting.handleOracleReport(e, report); + + uint256 contractEthBalanceAfter = LidoEthBalance(); + uint256 totalElRewardsAfter = getTotalELRewardsCollected(); + uint256 bufferedEthAfter = getBufferedEther(); + + assert assert_uint256(contractEthBalanceBefore + withdrawalsToWithdraw + elRewardsToWithdraw - etherToLockOnWithdrawalQueue) == contractEthBalanceAfter; + assert assert_uint256(totalElRewardsBefore + elRewardsToWithdraw) == totalElRewardsAfter; + assert assert_uint256(bufferedEthBefore + withdrawalsToWithdraw + elRewardsToWithdraw - etherToLockOnWithdrawalQueue) == bufferedEthAfter; +} + +/************************************************** + * MISC Rules * + **************************************************/ +use builtin rule sanity filtered { f -> + f.contract == currentContract && + f.selector != sig:transferToVault(address).selector +} + +rule transferToVaultAlwaysReverts() { + env e; + address a; + + transferToVault@withrevert(e, a); + + assert(lastReverted); +} \ No newline at end of file diff --git a/certora/specs/old-StEth.spec b/certora/specs/old-StEth.spec new file mode 100644 index 0000000000..cb224321eb --- /dev/null +++ b/certora/specs/old-StEth.spec @@ -0,0 +1,422 @@ +import "setup/snippet_memutils.spec"; + +using EIP712StETH as EIP712StETH; +using Kernel as Kernel; +using LidoLocator as LidoLocator; +using StakingRouter as StakingRouter; +using WithdrawalQueueERC721 as WithdrawalQueue; + +methods { + function name() external returns (string) envfree; + function symbol() external returns (string) envfree; + function decimals() external returns (uint8) envfree; + function totalSupply() external returns (uint256) envfree; + function balanceOf(address) external returns (uint256) envfree; + function allowance(address,address) external returns (uint256) envfree; + function approve(address,uint256) external returns (bool); + function transfer(address,uint256) external returns (bool); + function transferFrom(address,address,uint256) external returns (bool); + function increaseAllowance(address, uint256) external returns (bool); + function decreaseAllowance(address, uint256) external returns (bool); + + function getTotalPooledEther() external returns (uint256) envfree; + function getTotalShares() external returns (uint256) envfree; + function sharesOf(address) external returns (uint256) envfree; + function getSharesByPooledEth(uint256) external returns (uint256) envfree; + function getPooledEthByShares(uint256) external returns (uint256) envfree; + function transferShares(address, uint256) external returns (uint256); + function transferSharesFrom(address, address, uint256) external returns (uint256); + + // Lido + function pauseStaking() external; + function resumeStaking() external; + function setStakingLimit(uint256, uint256) external; + function removeStakingLimit() external; + function isStakingPaused() external returns (bool) envfree; + function getCurrentStakeLimit() external returns (uint256); + function getStakeLimitFullInfo() external returns (bool, bool, uint256, uint256, uint256, uint256, uint256); + function submit(address) external returns (uint256); //payable + function receiveELRewards() external; //payable + function depositBufferedEther() external; + function depositBufferedEther(uint256) external; + function burnShares(address, uint256) external returns (uint256); + function stop() external; + function resume() external; + function setFee(uint16) external; + function setFeeDistribution(uint16, uint16, uint16) external; + function setProtocolContracts(address, address, address) external; + function setWithdrawalCredentials(bytes32) external; + function setELRewardsVault(address) external; + function setELRewardsWithdrawalLimit(uint16) external; + function handleOracleReport(uint256, uint256) external; + function transferToVault(address) external; + function getFee() external returns (uint16) envfree; + function getFeeDistribution() external returns (uint16, uint16, uint16) envfree; + function getWithdrawalCredentials() external returns (bytes32) envfree; + function getBufferedEther() external returns (uint256) envfree; + function getTotalELRewardsCollected() external returns (uint256) envfree; + // function getELRewardsWithdrawalLimit() external returns (uint256) envfree; + // getDepositContract() public view returns (IDepositContract) + // getOperators() public view returns (INodeOperatorsRegistry) + function getTreasury() external returns (address) envfree; + // function getInsuranceFund() external returns (address) envfree; + function getBeaconStat() external returns (uint256, uint256, uint256) envfree; + // function getELRewardsVault() external returns (address) envfree; + + function Lido._stakingRouter() internal returns (address) => StakingRouter; + function Lido._withdrawalQueue() internal returns (address) => WithdrawalQueue; + function Lido._getLidoLocator() internal returns (address) => LidoLocator; + function LidoHarness.getEIP712StETH() internal returns (address) => EIP712StETH; + function LidoHarness.kernel() internal returns (address) => Kernel; + + function _.getRecoveryVault() external => CONSTANT; + + function isStopped() external returns (bool) envfree; + + function _.isValidSignature(address, bytes32, uint8, bytes32, bytes32) internal => NONDET; + function _.getStakingModuleSummary() external => NONDET; + function _.obtainDepositData(uint256,bytes) external => NONDET; + function Kernel.hasPermission(address,address,bytes32,bytes) external returns (bool) => NONDET; + function EVMScriptRunner.runScript(bytes memory,bytes memory,address[] memory) internal returns (bytes memory) => CVL_NONDET_bytes(); + function LidoHarness.getEVMScriptExecutor(bytes memory) internal returns (address) => NONDET; + + // mint(address,uint256) + // burn(uint256) + // burn(address,uint256) + // burnFrom(address,uint256) + // initialize(address) +} + +function CVL_NONDET_bytes() returns (bytes) { + bytes ret; + return ret; +} + +/** +Verify that there is no fee on transferFrom. +**/ +rule noFeeOnTransferFrom(address alice, address bob, uint256 amount) { + env e; + require alice != bob; + require allowance(alice, e.msg.sender) >= amount; + uint256 sharesBalanceBeforeBob = sharesOf(bob); + uint256 sharesBalanceBeforeAlice = sharesOf(alice); + + uint256 actualSharesAmount = getSharesByPooledEth(amount); + + transferFrom(e, alice, bob, amount); + + uint256 sharesBalanceAfterBob = sharesOf(bob); + uint256 sharesBalanceAfterAlice = sharesOf(alice); + + assert sharesBalanceAfterBob == assert_uint256(sharesBalanceBeforeBob + actualSharesAmount); + assert sharesBalanceAfterAlice == assert_uint256(sharesBalanceBeforeAlice - actualSharesAmount); +} + +/** +Verify that there is no fee on transferSharesFrom. +**/ +rule noFeeOnTransferSharesFrom(address alice, address bob, uint256 amount) { + env e; + require alice != bob; + require allowance(alice, e.msg.sender) >= amount; + uint256 sharesBalanceBeforeBob = sharesOf(bob); + uint256 sharesBalanceBeforeAlice = sharesOf(alice); + + transferSharesFrom(e, alice, bob, amount); + + uint256 sharesBalanceAfterBob = sharesOf(bob); + uint256 sharesBalanceAfterAlice = sharesOf(alice); + + assert sharesBalanceAfterBob == assert_uint256(sharesBalanceBeforeBob + amount); + assert sharesBalanceAfterAlice == assert_uint256(sharesBalanceBeforeAlice - amount); +} + +/** +Verify that there is no fee on transfer. +**/ +rule noFeeOnTransfer(address bob, uint256 amount) { + env e; + require bob != e.msg.sender; + uint256 balanceSenderBefore = sharesOf(e.msg.sender); + uint256 balanceBefore = sharesOf(bob); + + uint256 actualSharesAmount = getSharesByPooledEth(amount); + + transfer(e, bob, amount); + + uint256 balanceAfter = sharesOf(bob); + uint256 balanceSenderAfter = sharesOf(e.msg.sender); + assert balanceAfter == assert_uint256(balanceBefore + actualSharesAmount); + assert balanceSenderAfter == assert_uint256(balanceSenderBefore - actualSharesAmount); +} + +/** +Verify that there is no fee on transferShares. +**/ +rule noFeeOnTransferShares(address bob, uint256 amount) { + env e; + require bob != e.msg.sender; + uint256 balanceSenderBefore = sharesOf(e.msg.sender); + uint256 balanceBefore = sharesOf(bob); + + transferShares(e, bob, amount); + + uint256 balanceAfter = sharesOf(bob); + uint256 balanceSenderAfter = sharesOf(e.msg.sender); + assert balanceAfter == assert_uint256(balanceBefore + amount); + assert balanceSenderAfter == assert_uint256(balanceSenderBefore - amount); +} + +/** +Token transfer works correctly. Balances are updated if not reverted. +If reverted then the transfer amount was too high, or the recipient either 0, the same as the sender or the currentContract. +**/ +rule transferCorrect(address to, uint256 amount) { + env e; + require e.msg.value == 0 && e.msg.sender != 0; + uint256 fromBalanceBefore = sharesOf(e.msg.sender); + uint256 toBalanceBefore = sharesOf(to); + require fromBalanceBefore + toBalanceBefore <= max_uint256; + require isStopped() == false; + uint256 actualSharesAmount = getSharesByPooledEth(amount); + + transfer@withrevert(e, to, amount); + bool reverted = lastReverted; + if (!reverted) { + if (e.msg.sender == to) { + assert sharesOf(e.msg.sender) == fromBalanceBefore; + } else { + assert sharesOf(e.msg.sender) == assert_uint256(fromBalanceBefore - actualSharesAmount); + assert sharesOf(to) == assert_uint256(toBalanceBefore + actualSharesAmount); + } + } else { + assert actualSharesAmount > fromBalanceBefore || to == 0 || e.msg.sender == to || to == currentContract; + } +} + +/** +Test that transferFrom works correctly. Balances are updated if not reverted. +**/ +rule transferFromCorrect(address from, address to, uint256 amount) { + env e; + require e.msg.value == 0; + uint256 fromBalanceBefore = sharesOf(from); + uint256 toBalanceBefore = sharesOf(to); + uint256 allowanceBefore = allowance(from, e.msg.sender); + require fromBalanceBefore + toBalanceBefore <= max_uint256; + uint256 actualSharesAmount = getSharesByPooledEth(amount); + + transferFrom(e, from, to, amount); + + assert from != to => + sharesOf(from) == assert_uint256(fromBalanceBefore - actualSharesAmount) && + sharesOf(to) == assert_uint256(toBalanceBefore + actualSharesAmount); +} + +/** +Test that transferSharesFrom works correctly. Balances are updated if not reverted. +**/ +rule transferSharesFromCorrect(address from, address to, uint256 amount) { + env e; + require e.msg.value == 0; + uint256 fromBalanceBefore = sharesOf(from); + uint256 toBalanceBefore = sharesOf(to); + uint256 allowanceBefore = allowance(from, e.msg.sender); + require fromBalanceBefore + toBalanceBefore <= max_uint256; + uint256 tokenAmount = getPooledEthByShares(amount); + + transferSharesFrom(e, from, to, amount); + + assert from != to => + sharesOf(from) == assert_uint256(fromBalanceBefore - amount) && + sharesOf(to) == assert_uint256(toBalanceBefore + amount); +} + +/** +transferFrom should revert if and only if the amount is too high or the recipient is 0 or the contract itself. +**/ +rule transferFromReverts(address from, address to, uint256 amount) { + env e; + uint256 allowanceBefore = allowance(from, e.msg.sender); + uint256 fromBalanceBefore = sharesOf(from); + require from != 0 && e.msg.sender != 0; + require e.msg.value == 0; + require fromBalanceBefore + sharesOf(to) <= max_uint256; + require isStopped() == false; + uint256 actualSharesAmount = getSharesByPooledEth(amount); + + transferFrom@withrevert(e, from, to, amount); + + assert lastReverted <=> (allowanceBefore < amount || actualSharesAmount > fromBalanceBefore || to == 0 || to == currentContract); +} + +/** +transferFrom should revert if and only if the amount is too high or the recipient is 0 or the contract itself. +**/ +rule transferSharesFromReverts(address from, address to, uint256 amount) { + env e; + uint256 allowanceBefore = allowance(from, e.msg.sender); + uint256 fromBalanceBefore = sharesOf(from); + require from != 0 && e.msg.sender != 0; + require e.msg.value == 0; + require fromBalanceBefore + sharesOf(to) <= max_uint256; + require isStopped() == false; + uint256 tokenAmount = getPooledEthByShares(amount); + + transferSharesFrom@withrevert(e, from, to, amount); + + assert lastReverted <=> (allowanceBefore < tokenAmount || amount > fromBalanceBefore || to == 0 || to == currentContract); +} + +/** +Allowance changes correctly as a result of calls to approve, transferFrom, transferSharesFrom, increaseAllowance, decreaseAllowance. +**/ +rule ChangingAllowance(method f, address from, address spender) + filtered{ f -> + f.selector != sig:initialize(address, address).selector && + f.selector != sig:finalizeUpgrade_v3(address,address[]).selector && + f.selector != sig:transferToVault(address).selector + } { + uint256 allowanceBefore = allowance(from, spender); + env e; + if (f.selector == sig:approve(address, uint256).selector) { + address spender_; + uint256 amount; + approve(e, spender_, amount); + if (from == e.msg.sender && spender == spender_) { + assert allowance(from, spender) == amount; + } else { + assert allowance(from, spender) == allowanceBefore; + } + } else if (f.selector == sig:transferFrom(address,address,uint256).selector || f.selector == sig:transferSharesFrom(address,address,uint256).selector) { + address from_; + address to; + uint256 amount; + transferFrom(e, from_, to, amount); + uint256 allowanceAfter = allowance(from, spender); + if (from == from_ && spender == e.msg.sender) { + assert from == to || allowanceBefore == max_uint256 || allowanceAfter == assert_uint256(allowanceBefore - amount); + } else { + assert allowance(from, spender) == allowanceBefore; + } + } else if (f.selector == sig:decreaseAllowance(address, uint256).selector) { + address spender_; + uint256 amount; + require amount <= allowanceBefore; + decreaseAllowance(e, spender_, amount); + if (from == e.msg.sender && spender == spender_) { + assert allowance(from, spender) == assert_uint256(allowanceBefore - amount); + } else { + assert allowance(from, spender) == allowanceBefore; + } + } else if (f.selector == sig:increaseAllowance(address, uint256).selector) { + address spender_; + uint256 amount; + require amount + allowanceBefore < max_uint256; + increaseAllowance(e, spender_, amount); + if (from == e.msg.sender && spender == spender_) { + assert allowance(from, spender) == assert_uint256(allowanceBefore + amount); + } else { + assert allowance(from, spender) == allowanceBefore; + } + } else if (f.selector == sig:permit(address, address, uint256, uint256, uint8, bytes32, bytes32).selector) { + uint256 value; + uint256 deadline; + uint8 v; + bytes32 r; + bytes32 s; + permit(e, from, spender, value, deadline, v, r, s); + assert allowance(from, spender) == value; + } else { + calldataarg args; + f(e, args); + assert allowance(from, spender) == allowanceBefore; + } +} + +/** +Transfer from msg.sender to recipient doesn't change the balance of other addresses. +**/ +rule TransferDoesntChangeOtherBalance(address to, uint256 amount, address other) { + env e; + require other != e.msg.sender; + require other != to && other != currentContract; + uint256 balanceBefore = sharesOf(other); + transfer(e, to, amount); + assert balanceBefore == sharesOf(other); +} + +/** +Transfer from sender to recipient using transferFrom doesn't change the balance of other addresses. +**/ +rule TransferFromDoesntChangeOtherBalance(address from, address to, uint256 amount, address other) { + env e; + require other != from; + require other != to; + uint256 balanceBefore = sharesOf(other); + transferFrom(e, from, to, amount); + assert balanceBefore == sharesOf(other); +} + +/** +Transfer shares from sender to recipient using transferFrom doesn't change the balance of other addresses. +**/ +rule TransferSharesFromDoesntChangeOtherBalance(address from, address to, uint256 amount, address other) { + env e; + require other != from; + require other != to; + uint256 balanceBefore = sharesOf(other); + transferSharesFrom(e, from, to, amount); + assert balanceBefore == sharesOf(other); +} + +/************************************************** + * METHOD INTEGRITY * + **************************************************/ + +/************************************************** + * HIGH LEVEL * + **************************************************/ + +/************************************************** + * INVARIANTS * + **************************************************/ + +// invariant balanceOfCanrExceedTotalSuply(address user) +// balanceOf(user) <= totalSupply() + +// invariant sharesOfCantExceedTotalShares(address user) +// sharesOf(user) <= getTotalShares() + +// /** +// This rule finds which functions are privileged. +// A function is privileged if only one address can call it. +// The rule identifies this by checking which functions can be called by two different users. +// **/ +// rule privilegedOperation(method f, address privileged){ +// env e1; +// calldataarg arg; +// require e1.msg.sender == privileged; + +// storage initialStorage = lastStorage; +// f@withrevert(e1, arg); // privileged succeeds executing candidate privileged operation. +// bool firstSucceeded = !lastReverted; + +// env e2; +// calldataarg arg2; +// require e2.msg.sender != privileged; +// f@withrevert(e2, arg2) at initialStorage; // unprivileged +// bool secondSucceeded = !lastReverted; + +// assert !(firstSucceeded && secondSucceeded), "${f.selector} can be called by both ${e1.msg.sender} and ${e2.msg.sender}, so it is not privileged"; +// } + + +// rule sanity(method f) +// { +// env e; +// calldataarg arg; +// f(e, arg); +// assert false; +// } diff --git a/certora/specs/setup/dispatching_Dashboard.spec b/certora/specs/setup/dispatching_Dashboard.spec new file mode 100644 index 0000000000..c0bbb630f7 --- /dev/null +++ b/certora/specs/setup/dispatching_Dashboard.spec @@ -0,0 +1,24 @@ +import "snippet_lidomock.spec"; +import "snippet_proof.spec"; +import "snippet_StakingVault.spec"; +import "snippet_withdrawals.spec"; + +using StakingVault as StakingVault; + +methods { + function _._stakingVault() internal => CVL_stakingVault() expect address; + function _.deposit(bytes,bytes,bytes,bytes32) external => DISPATCHER(true); + + // dispatch in recoverERC20 + function _.transfer(address,uint256) external => DISPATCHER(true); + + // dispatch in recoverERC721 + function _.safeTransferFrom(address,address,uint256) external => DISPATCHER(true); + + // dispatch to ERC721RecipientMock + function _.onERC721Received(address,address,uint256,bytes) external => DISPATCHER(true); +} + +function CVL_stakingVault() returns address { + return StakingVault; +} diff --git a/certora/specs/setup/dispatching_LazyOracle.spec b/certora/specs/setup/dispatching_LazyOracle.spec new file mode 100644 index 0000000000..79e0b16117 --- /dev/null +++ b/certora/specs/setup/dispatching_LazyOracle.spec @@ -0,0 +1,11 @@ +import "snippet_StakingVault.spec"; + +using StakingVault as StakingVault; + +methods { + function _._stakingVault() internal => CVL_stakingVault() expect address; +} + +function CVL_stakingVault() returns address { + return StakingVault; +} diff --git a/certora/specs/setup/dispatching_OperatorGrid.spec b/certora/specs/setup/dispatching_OperatorGrid.spec new file mode 100644 index 0000000000..4cf18b0565 --- /dev/null +++ b/certora/specs/setup/dispatching_OperatorGrid.spec @@ -0,0 +1,3 @@ +import "snippet_StakingVault.spec"; +methods { +} diff --git a/certora/specs/setup/dispatching_PredepositGuarantee.spec b/certora/specs/setup/dispatching_PredepositGuarantee.spec new file mode 100644 index 0000000000..67853411ae --- /dev/null +++ b/certora/specs/setup/dispatching_PredepositGuarantee.spec @@ -0,0 +1,11 @@ +import "snippet_proof.spec"; + +methods { + function _.verifyDepositMessage(IStakingVault.Deposit calldata, BLS12_381.DepositY calldata, bytes32) internal => NONDET; + + // dispatch local variables to StakingVault + function _.withdrawalCredentials() external => DISPATCHER(true); + function _.owner() external => DISPATCHER(true); + function _.nodeOperator() external => DISPATCHER(true); + function _.depositToBeaconChain(IStakingVault.Deposit) external => DISPATCHER(true); +} diff --git a/certora/specs/setup/dispatching_StakingVault.spec b/certora/specs/setup/dispatching_StakingVault.spec new file mode 100644 index 0000000000..c923b168bf --- /dev/null +++ b/certora/specs/setup/dispatching_StakingVault.spec @@ -0,0 +1,4 @@ +import "snippet_beacon.spec"; +import "snippet_withdrawals.spec"; + +methods {} diff --git a/certora/specs/setup/dispatching_VaultFactory.spec b/certora/specs/setup/dispatching_VaultFactory.spec new file mode 100644 index 0000000000..372c9898b8 --- /dev/null +++ b/certora/specs/setup/dispatching_VaultFactory.spec @@ -0,0 +1,15 @@ +import "snippet_IHashConsensusMock.spec"; +import "snippet_StakingVault.spec"; + +methods { + function _._stakingVault() internal => CONSTANT; + function _.verify(bytes32[] memory, bytes32, bytes32) internal => NONDET; + + // dispatch local variables to Dashboard + function _.initialize(address,address,address,uint256,uint256) external => DISPATCHER(true); + function _.grantRoles(Permissions.RoleAssignment[]) external => DISPATCHER(true); + function _.grantRole(bytes32, address) external => DISPATCHER(true); + function _.revokeRole(bytes32, address) external => DISPATCHER(true); + function _.DEFAULT_ADMIN_ROLE() external => DISPATCHER(true); + function _.NODE_OPERATOR_MANAGER_ROLE() external => DISPATCHER(true); +} diff --git a/certora/specs/setup/dispatching_VaultHub.spec b/certora/specs/setup/dispatching_VaultHub.spec new file mode 100644 index 0000000000..3f1a8794ba --- /dev/null +++ b/certora/specs/setup/dispatching_VaultHub.spec @@ -0,0 +1,7 @@ +import "snippet_lidomock.spec"; +import "snippet_proof.spec"; +import "snippet_StakingVault.spec"; +import "snippet_withdrawals.spec"; + +methods { +} diff --git a/certora/specs/setup/sanity_Dashboard.spec b/certora/specs/setup/sanity_Dashboard.spec new file mode 100644 index 0000000000..3749ffa759 --- /dev/null +++ b/certora/specs/setup/sanity_Dashboard.spec @@ -0,0 +1,2 @@ +import "dispatching_Dashboard.spec"; +use builtin rule sanity filtered { f -> f.contract == currentContract } diff --git a/certora/specs/setup/sanity_LazyOracle.spec b/certora/specs/setup/sanity_LazyOracle.spec new file mode 100644 index 0000000000..ed7c93125c --- /dev/null +++ b/certora/specs/setup/sanity_LazyOracle.spec @@ -0,0 +1,2 @@ +import "dispatching_LazyOracle.spec"; +use builtin rule sanity filtered { f -> f.contract == currentContract } diff --git a/certora/specs/setup/sanity_OperatorGrid.spec b/certora/specs/setup/sanity_OperatorGrid.spec new file mode 100644 index 0000000000..4857120cd0 --- /dev/null +++ b/certora/specs/setup/sanity_OperatorGrid.spec @@ -0,0 +1,2 @@ +import "dispatching_OperatorGrid.spec"; +use builtin rule sanity filtered { f -> f.contract == currentContract } diff --git a/certora/specs/setup/sanity_PredepositGuarantee.spec b/certora/specs/setup/sanity_PredepositGuarantee.spec new file mode 100644 index 0000000000..d5a624d5be --- /dev/null +++ b/certora/specs/setup/sanity_PredepositGuarantee.spec @@ -0,0 +1,2 @@ +import "dispatching_PredepositGuarantee.spec"; +use builtin rule sanity filtered { f -> f.contract == currentContract } diff --git a/certora/specs/setup/sanity_StakingVault.spec b/certora/specs/setup/sanity_StakingVault.spec new file mode 100644 index 0000000000..b442fbc325 --- /dev/null +++ b/certora/specs/setup/sanity_StakingVault.spec @@ -0,0 +1,2 @@ +import "dispatching_StakingVault.spec"; +use builtin rule sanity filtered { f -> f.contract == currentContract } diff --git a/certora/specs/setup/sanity_VaultFactory.spec b/certora/specs/setup/sanity_VaultFactory.spec new file mode 100644 index 0000000000..60d0447ef8 --- /dev/null +++ b/certora/specs/setup/sanity_VaultFactory.spec @@ -0,0 +1,2 @@ +import "dispatching_VaultFactory.spec"; +use builtin rule sanity filtered { f -> f.contract == currentContract } diff --git a/certora/specs/setup/sanity_VaultHub.spec b/certora/specs/setup/sanity_VaultHub.spec new file mode 100644 index 0000000000..637a88b6cd --- /dev/null +++ b/certora/specs/setup/sanity_VaultHub.spec @@ -0,0 +1,2 @@ +import "dispatching_VaultHub.spec"; +use builtin rule sanity filtered { f -> f.contract == currentContract } diff --git a/certora/specs/setup/snippet_IHashConsensusMock.spec b/certora/specs/setup/snippet_IHashConsensusMock.spec new file mode 100644 index 0000000000..037a6dfa6b --- /dev/null +++ b/certora/specs/setup/snippet_IHashConsensusMock.spec @@ -0,0 +1,7 @@ +methods { + function IHashConsensusMock.getIsMember(address) external returns (bool) => NONDET; + function IHashConsensusMock.getCurrentFrame() external returns (uint256,uint256) => NONDET; + function IHashConsensusMock.getChainConfig() external returns (uint256,uint256,uint256) => NONDET; + function IHashConsensusMock.getFrameConfig() external returns (uint256,uint256) => NONDET; + function IHashConsensusMock.getInitialRefSlot() external returns (uint256) => NONDET; +} diff --git a/certora/specs/setup/snippet_StakingVault.spec b/certora/specs/setup/snippet_StakingVault.spec new file mode 100644 index 0000000000..09b679d792 --- /dev/null +++ b/certora/specs/setup/snippet_StakingVault.spec @@ -0,0 +1,20 @@ +methods { + // dispatch local variables to StakingVault + function _.DEPOSIT_CONTRACT() external => DISPATCHER(true); + function _.initialize(address,address,address) external => DISPATCHER(true); + function _.withdrawalCredentials() external => DISPATCHER(true); + function _.owner() external => DISPATCHER(true); + function _.pendingOwner() external => DISPATCHER(true); + function _.acceptOwnership() external => DISPATCHER(true); + function _.transferOwnership(address) external => DISPATCHER(true); + function _.nodeOperator() external => DISPATCHER(true); + function _.depositor() external => DISPATCHER(true); + function _.fund() external => DISPATCHER(true); + function _.withdraw(address,uint256) external => DISPATCHER(true); + function _.beaconChainDepositsPaused() external => DISPATCHER(true); + function _.pauseBeaconChainDeposits() external => DISPATCHER(true); + function _.resumeBeaconChainDeposits() external => DISPATCHER(true); + function _.triggerValidatorWithdrawals(bytes,uint64[],address) external => DISPATCHER(true); + // merely emits an event, no need to dispatch + function _.requestValidatorExit(bytes) external => NONDET; +} diff --git a/certora/specs/setup/snippet_beacon.spec b/certora/specs/setup/snippet_beacon.spec new file mode 100644 index 0000000000..61c29ea90a --- /dev/null +++ b/certora/specs/setup/snippet_beacon.spec @@ -0,0 +1,4 @@ +// summarizes IBeacon, makes it a constant +methods { + function _.implementation() external => PER_CALLEE_CONSTANT; +} \ No newline at end of file diff --git a/certora/specs/setup/snippet_lidomock.spec b/certora/specs/setup/snippet_lidomock.spec new file mode 100644 index 0000000000..528fb85bb2 --- /dev/null +++ b/certora/specs/setup/snippet_lidomock.spec @@ -0,0 +1,9 @@ +methods { + function ILidoMock.totalSupply() external returns (uint256) => NONDET; + function ILidoMock.balanceOf(address) external returns (uint256) => NONDET; + + function ILidoMock.sharesOf(address) external returns (uint256) => NONDET; + function ILidoMock.getSharesByPooledEth(uint256) external returns (uint256) => NONDET; + function ILidoMock.getPooledEthByShares(uint256) external returns (uint256) => NONDET; + function ILidoMock.getPooledEthBySharesRoundUp(uint256) external returns (uint256) => NONDET; +} \ No newline at end of file diff --git a/certora/specs/setup/snippet_memutils.spec b/certora/specs/setup/snippet_memutils.spec new file mode 100644 index 0000000000..e4a0933d87 --- /dev/null +++ b/certora/specs/setup/snippet_memutils.spec @@ -0,0 +1,13 @@ +methods { + function _.unsafeAllocateBytes(uint256 _len) internal => CVL_unsafeAllocateBytes(_len) expect (bytes memory); + function _.memcpy(uint256 _src, uint256 _dst, uint256 _len) internal => NONDET; + + function _.dangerouslyCastUintArrayToBytes(uint256[] memory _input) internal + => CVL_unsafeAllocateBytes(require_uint256(_input.length * 32)) expect (bytes memory); +} + +function CVL_unsafeAllocateBytes(uint256 len) returns (bytes) { + bytes ret; + require(ret.length == len); + return ret; +} diff --git a/certora/specs/setup/snippet_proof.spec b/certora/specs/setup/snippet_proof.spec new file mode 100644 index 0000000000..1359727f6f --- /dev/null +++ b/certora/specs/setup/snippet_proof.spec @@ -0,0 +1,6 @@ +// summarizes call to CLProofVerifier +// The Prover is unable to find `CLProofVerifier` for some reason (it did work in +// previous versions of the code), so we switched to using a wildcard. +methods { + function _._validatePubKeyWCProof(IPredepositGuarantee.ValidatorWitness calldata, bytes32) internal => NONDET; +} diff --git a/certora/specs/setup/snippet_withdrawals.spec b/certora/specs/setup/snippet_withdrawals.spec new file mode 100644 index 0000000000..694b353cc4 --- /dev/null +++ b/certora/specs/setup/snippet_withdrawals.spec @@ -0,0 +1,14 @@ +// summarizes withdrawals +// https://eips.ethereum.org/EIPS/eip-7002 +methods { + function _.addFullWithdrawalRequests(bytes, uint256) external => NONDET; + function _.addFullWithdrawalRequests(bytes calldata, uint256) internal => NONDET; + + function _.addWithdrawalRequests(bytes, uint64[], uint256) external => NONDET; + function _.addWithdrawalRequests(bytes calldata, uint64[] calldata, uint256) internal => NONDET; + + function _.getWithdrawalRequestFee() external => NONDET; + function _.getWithdrawalRequestFee() internal => NONDET; + + function WithdrawableRequestMock._ external => NONDET; +} diff --git a/certora/specs/vaults/VaultHub.spec b/certora/specs/vaults/VaultHub.spec new file mode 100644 index 0000000000..30bc193473 --- /dev/null +++ b/certora/specs/vaults/VaultHub.spec @@ -0,0 +1,779 @@ +/* `VaultHUb` properties + +NOTE: There is here an implicit assumption that the conversion ratio +(`getPooledEthByShares`) only changes by calls to `rebalanceExternalEtherToInternal`. +*/ + +import "./vaults-array.spec"; +import "./lido-mock.spec"; +import "../common/erc20-summary.spec"; + +// `using VaultHubHarness as _VaultHub;` defined in `vaults-array.spec` +using PredepositGuarantee as _PredepositGuarantee; +using OperatorGrid as _OperatorGrid; +using ILidoMock as _Lido; + +methods { + // `LidoLocator` + function _.vaultHub() external => _VaultHub expect address; + function _.lido() external => _Lido expect address; + function _.operatorGrid() external => _OperatorGrid expect address; + function _.accounting() external => NONDET; + + // `LazyOracle` + function _.latestReportTimestamp() external => NONDET; + function _.removeVaultQuarantine(address) external => NONDET; + + // `StakingVault` + // Without the following summary, the call from `VaultHub`:Line 1071, + // `_predepositGuarantee().proveUnknownValidator(_witness, IStakingVault(_vault))`, + // becomes unresolved ("callee contract unresolved"). + function _.withdraw(address, uint256) external => DISPATCHER(true); + + function _.availableBalance() external => DISPATCHER(true); + function _.beaconChainDepositsPaused() external => DISPATCHER(true); + function _.resumeBeaconChainDeposits() external => DISPATCHER(true); + function _.pauseBeaconChainDeposits() external => DISPATCHER(true); + function _.transferOwnership(address) external => DISPATCHER(true); + function _.pendingOwner() external => DISPATCHER(true); + function _.depositor() external => DISPATCHER(true); + function _.owner() external => DISPATCHER(true); + function _.nodeOperator() external => DISPATCHER(true); + function _.acceptOwnership() external => DISPATCHER(true); + function _.fund() external => DISPATCHER(true); + function _.requestValidatorExit(bytes) external => DISPATCHER(true); + function _.triggerValidatorWithdrawals(bytes, uint64[], address) external => DISPATCHER(true); + function _.collectERC20(address, address, uint256) external => DISPATCHER(true); + + // Summarize the call to `WITHDRAWAL_REQUEST` in `TriggerableWithdrawals` library + // as `NONDET`. NOTE: This is not sound but necessary for analysis. + unresolved external in StakingVault.triggerValidatorWithdrawals( + bytes, uint64[], address + ) => DISPATCH [] default NONDET; + + // `OperatorGrid` + function OperatorGrid.tier(uint256) external returns (OperatorGrid.Tier) envfree; + function OperatorGrid.tiersCount() external returns (uint256) envfree; + //function Confirmable2Addresses._collectAndCheckConfirmations(bytes calldata _calldata, address _role1, address _role2) internal returns (bool) => NONDET; + + // `PredepositGuarantee` + // Without the following summary, the call from `VaultHub`:Line 929, + // `_predepositGuarantee().proveUnknownValidator(_witness, IStakingVault(_vault))`, + // becomes unresolved ("callee contract unresolved"). + function _.proveUnknownValidator( + IPredepositGuarantee.ValidatorWitness, address + ) external => DISPATCHER(true); + + // `BLS` Library + // Summarizing the `BLS` library since the Prover cannot easily handle such + // calculations and it contains many unsafe memory operations that hurt static + // analysis. Using NONDET as it's the most practical approach for verification. + function BLS12_381.verifyDepositMessage( + bytes calldata, + bytes calldata, + uint256, + BLS12_381.DepositY calldata, + bytes32, + bytes32 + ) internal => NONDET; + function BLS12_381.sha256Pair(bytes32, bytes32) internal returns (bytes32) => NONDET; + function BLS12_381.pubkeyRoot(bytes calldata) internal returns (bytes32) => NONDET; + + // `SSZ` Library + // NOTE: Summarized as NONDET due to complexity of SSZ operations + function SSZ.hashTreeRoot(SSZ.BeaconBlockHeader memory) internal returns (bytes32) => NONDET; + function SSZ.hashTreeRoot(SSZ.Validator memory) internal returns (bytes32) => NONDET; + function SSZ.verifyProof(bytes32[] calldata, bytes32, bytes32, SSZ.GIndex) internal => NONDET; + + // `CLProofVerifier` + // NOTE: Using wildcard and NONDET as the Prover cannot resolve CLProofVerifier + // (it worked in previous versions of the code `d1b4b34ebc911f01aca285d8d7b758f8c5fc7619`) + function _._validatePubKeyWCProof( + IPredepositGuarantee.ValidatorWitness calldata, + bytes32 + ) internal => NONDET; +} + +// -- Property: `vaults` array in `VaultHub` is a set -------------------------- + +use invariant disconnectedVaultIsNotPending; +use invariant vaultsArrayIsNeverEmpty; +use invariant indexToVaultIsCorrect; +use invariant vaultToIndexIsCorrect; + +// -- Utility functions -------------------------------------------------------- + +/// @dev The same as `VaultHub.TOTAL_BASIS_POINTS` +definition TOTAL_BASIS_POINTS() returns uint256 = 10000; + +/// @dev The same as `OperatorGris.MAX_RESERVE_RATIO_BP` +definition TIER_MAX_RESERVE_RATIO_BP() returns uint256 = 9999; + + +/// @dev Non-view functions of `OperatorGrid` and `VaultHub` except for those in +/// `VaultHub` that can be called only from `OperatorGrid`. +definition isValidFuncVaultHubOperatorGrid(method f) returns bool = ( + !f.isView && ( + f.contract == _OperatorGrid || ( + f.contract == _VaultHub && + // This function is only called by the `OperatorGrid` + f.selector != sig:VaultHubHarness.updateConnection( + address, uint256, uint256, uint256, uint256, uint256, uint256 + ).selector + ) + ) +); + + +/// @dev Requirements for nice violation examples +function niceViolationRequirements(address vault) { + uint256 totalValue = _VaultHub.totalValue(vault); + uint256 shares = _VaultHub.liabilityShares(vault); + uint256 sharesValue = CVLgetPooledEthBySharesRoundUp(shares); + + require(totalValue == 1000 && shares >= 100, "Simpler example"); + require( + _VaultHub.forcedRebalanceThresholdBP(vault) == 2000 && + _VaultHub.reserveRatioBP(vault) == 2000, // 20% + "Assume small or simple values for simpler example" + ); + require( + _internalShares() >= 100 * _VaultHub.liabilityShares(vault) && + _internalEth >= 100 * _VaultHub.totalValue(vault), + "Assume Lido holds many more shares and ETH than the vault" + ); + require(CVLgetPooledEthByShares(1) >= 1, "Assume 1 share is more than 1 ETH"); + require(_VaultHub.isVaultConnected(vault), "Assume connected vault"); +} + + +// -- Invariants --------------------------------------------------------------- + +/// @title A vault with obligations is connected +/// @notice There is no check in `VaultHub.applyVaultReport` nor in `LazyOracle.updateVaultData` +/// (which calls the former) that the vault is indeed connected! +/// This makes this invariant fail for `VaultHub.applyVaultReport` +invariant obligatedVaultIsConnected(address vault) + ( + (_VaultHub.obligationsShares(vault) > 0 || _VaultHub.unsettledLidoFees(vault) > 0) => _VaultHub.isVaultConnected(vault) + ) + filtered { f -> f.contract == _VaultHub } + { + preserved _VaultHub.applyVaultReport( + address _other, + uint256 _reportTimestamp, + uint256 _reportTotalValue, + int256 _reportInOutDelta, + uint256 _reportCumulativeLidoFees, + uint256 _reportLiabilityShares, + uint256 _reportMaxLiabilityShares, + uint256 _reportSlashingReserve + ) with (env e) { + applyVaultReportRquirements(_other); + requireInvariant disconnectedVaultHasNoLiability(vault); + requireInvariant disconnectedVaultHasNoLocked(vault); + } + preserved _VaultHub.triggerValidatorWithdrawals(address _vault, bytes _pubkeys, uint64[] _amountsInGwei, address _refundRecipient) with (env e) { + require(_amountsInGwei.length <= 2, "Limit loop iterations to mitigate timeouts"); + } + preserved _VaultHub.forceRebalance(address _vault) with (env e) { + // Constrain the vault parameter to reduce complexity + requireInvariant disconnectedVaultHasNoLiability(vault); + requireInvariant disconnectedVaultHasNoLocked(vault); + } + preserved _VaultHub.forceValidatorExit(address _vault, bytes _pubkeys, address _refundRecipient) with (env e) { + // Limit the complexity by constraining pubkeys length + require(_pubkeys.length <= 96, "Limit to 2 validators (48 bytes each)"); + requireInvariant disconnectedVaultHasNoLiability(vault); + requireInvariant disconnectedVaultHasNoLocked(vault); + } + } + + +/// @title A disconnected vault has zero liability shares +invariant disconnectedVaultHasNoLiability(address vault) + !_VaultHub.isVaultConnected(vault) => _VaultHub.liabilityShares(vault) == 0 + filtered { f -> isValidFuncVaultHubOperatorGrid(f) } + { + preserved _VaultHub.applyVaultReport( + address _other, + uint256 _reportTimestamp, + uint256 _reportTotalValue, + int256 _reportInOutDelta, + uint256 _reportCumulativeLidoFees, + uint256 _reportLiabilityShares, + uint256 _reportMaxLiabilityShares, + uint256 _reportSlashingReserve + ) with (env e) { + applyVaultReportRquirements(_other); + } + } + + +/// @title A vault with locked value is connected +/// @notice Previously there is no check in `VaultHub.applyVaultReport` nor in +/// `LazyOracle.updateVaultData` (which calls the former) that the vault is indeed +/// connected! This made this invariant fail for `VaultHub.applyVaultReport`. +invariant disconnectedVaultHasNoLocked(address vault) + !_VaultHub.isVaultConnected(vault) => _VaultHub.locked(vault) == 0 + filtered { f -> isValidFuncVaultHubOperatorGrid(f) } + { + preserved _VaultHub.applyVaultReport( + address _other, + uint256 _reportTimestamp, + uint256 _reportTotalValue, + int256 _reportInOutDelta, + uint256 _reportCumulativeLidoFees, + uint256 _reportLiabilityShares, + uint256 _reportMaxLiabilityShares, + uint256 _reportSlashingReserve + ) with (env e) { + applyVaultReportRquirements(_other); + } + preserved _VaultHub.connectVault(address _other) with (env e) { + connectVaultRequirements(); + } + } + + +invariant tierReserveRatioLeqOne(uint256 tierId) + _OperatorGrid.og_storage.tiers[tierId].reserveRatioBP <= TIER_MAX_RESERVE_RATIO_BP() + filtered { f -> isValidFuncVaultHubOperatorGrid(f) } + { + preserved constructor() { + require _OperatorGrid.og_storage.tiers[tierId].reserveRatioBP <= TIER_MAX_RESERVE_RATIO_BP(); + } + } + + + + +/// @title A vaults reserve ratio is at most 100% +invariant reserveRatioNotBig(address vault) + _VaultHub.reserveRatioBP(vault) <= TOTAL_BASIS_POINTS() + filtered { f -> isValidFuncVaultHubOperatorGrid(f) } + { + preserved _OperatorGrid.changeTier(address _, uint256 requestedTierId, uint256 _) with (env e) { + requireInvariant tierReserveRatioLeqOne(requestedTierId); + } + preserved _OperatorGrid.syncTier(address _vault) with (env e) { + uint256 vaultTierId = _OperatorGrid.og_storage.vaultTier[_vault]; + requireInvariant tierReserveRatioLeqOne(vaultTierId); + } + preserved _VaultHub.connectVault(address _vault) with (env e) { + uint256 vaultTierId = _OperatorGrid.og_storage.vaultTier[_vault]; + requireInvariant tierReserveRatioLeqOne(vaultTierId); + } + } + + + +invariant everyNonDefaultTierHasGroup(uint256 tierId,address nodeOperator) + ( + tierId > 0 && tierId < _OperatorGrid.og_storage.tiers.length + ) => ( + _OperatorGrid.og_storage.groups[ + _OperatorGrid.og_storage.tiers[tierId].operator + ].operator != 0 + ) + filtered { + f -> f.contract == _OperatorGrid + } + { + preserved OperatorGrid.registerTiers(address _nodeOperator, OperatorGrid.TierParams[] _tiers) with (env e) { + // The registerTiers function checks that the group exists before creating tiers (line 274) + require _OperatorGrid.og_storage.groups[_nodeOperator].operator != 0; + // Constrain the number of tiers to prevent timeouts + require _tiers.length <= 50; + } + preserved OperatorGrid.initialize(address _admin, OperatorGrid.TierParams _defaultTierParams) with (env e) { + // After initialization, no group should exist yet (groups are registered separately) + // So the invariant should hold vacuously + require _OperatorGrid.og_storage.groups[nodeOperator].operator == 0; + } + } + + +/// @dev Returns the value n ETH of a vaults liability shares (rounded up) +definition liabilityEth(address vault) returns uint256 = ( + CVLgetPooledEthBySharesRoundUp(_VaultHub.liabilityShares(vault)) +); + +/// @dev Functions that can reduce the vault's total value (excluding `applyVaultReport`). +definition isReducingVaultTotal(method f) returns bool = ( + f.selector == sig:VaultHubHarness.rebalance(address, uint256).selector || + f.selector == sig:VaultHubHarness.forceRebalance(address).selector || + // The following functions may reduce the total balance by calling `_settleObligations` + f.selector == sig:VaultHubHarness.resumeBeaconChainDeposits(address).selector || + f.selector == sig:VaultHubHarness.settleLidoFees(address).selector +); + + +/// @title The locked amount of a vault covers its shares and reserve +/// @notice Violated in the following functions: +/// - `OperatorGrid.changeTier` see `https://github.com/lidofinance/core/issues/1272` +/// - `VaultHub.rebalance`- caused by internal ETH to internal shares increasing +/// (because another vault rebalanced). +/// See `https://github.com/lidofinance/core/issues/1309`. +/// See also `./immutable-ratio.spec` proving this issue, job run: +/// `https://prover.certora.com/output/98279/2558a54109a548b4b3806d020a21a93e` +/// - `VaultHub.resumeBeaconChainDeposits` - the same. +/// - `VaultHub.settleVaultObligations` - the same. +/// - `VaultHub.forceRebalance` - the same. +/// @notice The following violations prevented by requires (safety verified via preconditions) +/// - `VaultHub.applyVaultReport` - Unsafe casting to `uint128` in `_applyVaultReport` +/// - `VaultHub.mintShares` - Unsafe casting to `uint128` in `_increaseLiability` +invariant vaultLockedCoversLiabilityAndReserve(address vault) + (_VaultHub.reserveRatioBP(vault) < TOTAL_BASIS_POINTS()) => + (_VaultHub.locked(vault) >= (liabilityEth(vault) * TOTAL_BASIS_POINTS() / (TOTAL_BASIS_POINTS() - _VaultHub.reserveRatioBP(vault)))) + filtered { f -> isValidFuncVaultHubOperatorGrid(f) } + { + preserved { + requireInvariant vaultReserveRatioGeThreshold(vault); + } + preserved _VaultHub.mintShares( + address _vault, address _recipient, uint256 _amountOfShares + ) with (env e) { + requireInvariant vaultReserveRatioGeThreshold(vault); + require( + // Could use `2^128/TOTAL_BASIS_POINTS()` below + _VaultHub.liabilityShares(vault) < 2^100, + "Avoid underflow in unsafe casting in VaultHub:Line 1095" + ); + } + preserved _OperatorGrid.changeTier(address _vault, uint256 _requestedTierId, uint256 _requestedShareLimit) with (env e) { + requireInvariant tierReserveRatioGeThreshold(_requestedTierId); + requireInvariant maxLiabilitySharesGeqLiabilityShares(e, _vault); + require (_VaultHub.reserveRatioBP(vault) < TOTAL_BASIS_POINTS()); + } + preserved _OperatorGrid.syncTier(address _vault) with (env e) { + requireInvariant maxLiabilitySharesGeqLiabilityShares(e, _vault); + } + preserved _VaultHub.applyVaultReport( + address _vault, + uint256 _reportTimestamp, + uint256 _reportTotalValue, + int256 _reportInOutDelta, + uint256 _reportCumulativeLidoFees, + uint256 _reportLiabilityShares, + uint256 _reportMaxLiabilityShares, + uint256 _reportSlashingReserve + ) with (env e) { + reasonableDeltaValues(_vault); + applyVaultReportRquirements(_vault); + requireInvariant vaultReserveRatioGeThreshold(_vault); + require(_VaultHub.reserveRatioBP(_vault) < TOTAL_BASIS_POINTS()); + requireInvariant maxLiabilitySharesGeqLiabilityShares(e, _vault); + require( + // Could use `2^128/TOTAL_BASIS_POINTS()` below + CVLgetPooledEthBySharesRoundUp(_reportLiabilityShares) < 2^100 && + CVLgetPooledEthBySharesRoundUp(_VaultHub.liabilityShares(vault)) < 2^100, + "Avoid underflow in unsafe casting in VaultHub:Line 1042" + ); + // These overflow otherwise due to unchecked downcasts + require(_reportSlashingReserve < 2^128, "Prevent overflow of minimal reserve"); + require(_reportLiabilityShares < 2^92, "Prevent overflow of maxLiabilityShares"); + require(_reportTotalValue < 2^104, "Prevent overflow of _reportTotalValue"); + require(-2^103 < _reportInOutDelta && _reportInOutDelta < 2^103, "Prevent under/overflow of _reportInOutDelta"); + + } + } + +/* +/// @dev For creating a reasonable counter-example +invariant vaultLockedCoversLiabilityAndReserveiViolations(address vault) + (_VaultHub.reserveRatioBP(vault) < TOTAL_BASIS_POINTS()) => ( + _VaultHub.locked(vault) >= ( + liabilityEth(vault) * TOTAL_BASIS_POINTS() / + (TOTAL_BASIS_POINTS() - _VaultHub.reserveRatioBP(vault)) + ) + ) + filtered { f -> isValidFuncVaultHubOperatorGrid(f) } + { + preserved { + niceViolationRequirements(vault); + requireInvariant vaultReserveRatioGeThreshold(vault); + } + preserved _VaultHub.mintShares( + address _vault, address _recipient, uint256 _amountOfShares + ) with (env e) { + niceViolationRequirements(vault); + requireInvariant vaultReserveRatioGeThreshold(vault); + require( + _VaultHub.liabilityShares(vault) < max_uint256 / TOTAL_BASIS_POINTS(), + "Avoid overflow in BP calculations" + ); + } + preserved _OperatorGrid.changeTier( + address _vault, uint256 _requestedTierId, uint256 _requestedShareLimit + ) with (env e) { + requireInvariant tierReserveRatioGeThreshold(_requestedTierId); + } + preserved _VaultHub.applyVaultReport( + address _other, + uint256 _reportTimestamp, + uint256 _reportTotalValue, + int256 _reportInOutDelta, + uint256 _reportCumulativeLidoFees, + uint256 _reportLiabilityShares, + uint256 _reportMaxLiabilityShares, + uint256 _reportSlashingReserve + ) with (env e) { + niceViolationRequirements(vault); + applyVaultReportRquirements(_other); + requireInvariant vaultReserveRatioGeThreshold(vault); + require( + _VaultHub.liabilityShares(vault) < max_uint256 / TOTAL_BASIS_POINTS(), + "Avoid overflow in BP calculations" + ); + } + } +*/ + +invariant maxLiabilitySharesGeqLiabilityShares(env e, address vault) + _VaultHub.maxLiabilityShares(e,vault) >= _VaultHub.liabilityShares(e, vault) + filtered { + f -> f.contract == _VaultHub || f.contract == _OperatorGrid + && + // Exclude proveUnknownValidatorToPDG - doesn't modify liability or maxLiability as it times out + f.selector != sig:VaultHubHarness.proveUnknownValidatorToPDG( + address, IPredepositGuarantee.ValidatorWitness + ).selector + } + { + preserved _VaultHub.applyVaultReport( + address _vault, + uint256 _reportTimestamp, + uint256 _reportTotalValue, + int256 _reportInOutDelta, + uint256 _reportCumulativeLidoFees, + uint256 _reportLiabilityShares, + uint256 _reportMaxLiabilityShares, + uint256 _reportSlashingReserve + ) with (env e2) { + // Prevent overflow in unsafe downcasts to uint96 on line 1109 + require _reportLiabilityShares <= max_uint96; + require _reportMaxLiabilityShares <= max_uint96; + + requireInvariant disconnectedVaultHasNoLiability(_vault); + requireInvariant disconnectedVaultHasNoLocked(_vault); + } + } + + +// -- Rules for tiers ---------------------------------------------------------- + +definition numTiers() returns uint256 = _OperatorGrid.tiersCount(); + +/// @dev Using this definition, otherwise pushing a new tier will be ignored +definition tierReserveRatioBP(uint256 tierId) returns uint16 = ( + tierId < numTiers() ? _OperatorGrid.tier(tierId).reserveRatioBP : 0 +); + +/// @dev Using this definition, otherwise pushing a new tier will be ignored +definition tierforcedRebalanceThresholdBP(uint256 tierId) returns uint16 = ( + tierId < numTiers() ? _OperatorGrid.tier(tierId).forcedRebalanceThresholdBP : 0 +); + +/// @title For each tier the reserve ratio is greater than the force rebalance threshold +/// @notice Violated because the `initialize` function does not validate the parameters +/// (i.e. it does not call `_validateParams`). +/// See `https://github.com/lidofinance/core/issues/1291`. +invariant tierReserveRatioGeThreshold(uint256 tierId) + tierId < numTiers() => ( + tierReserveRatioBP(tierId) > 0 => tierReserveRatioBP(tierId) > tierforcedRebalanceThresholdBP(tierId) + ) + filtered { f -> f.contract == _OperatorGrid } + { + preserved constructor() { + require tierId >= numTiers() || tierReserveRatioBP(tierId) == 0 || + tierReserveRatioBP(tierId) > tierforcedRebalanceThresholdBP(tierId); + } + } + + +/// @title For every vault its reserve ratio is greater than its force rebalance threshold +invariant vaultReserveRatioGeThreshold(address vault) + _VaultHub.reserveRatioBP(vault) > 0 => _VaultHub.reserveRatioBP(vault) > _VaultHub.forcedRebalanceThresholdBP(vault) + filtered { + f -> ( + f.contract == _OperatorGrid || + ( + f.contract == _VaultHub && + // This function is only called by the `OperatorGrid` + f.selector != sig:VaultHubHarness.updateConnection( + address, uint256, uint256, uint256, uint256, uint256, uint256 + ).selector + ) + ) + } + { + preserved _OperatorGrid.syncTier(address _vault) with (env e) { + require _VaultHub.isVaultConnected(_vault); + uint256 tierId = _OperatorGrid.og_storage.vaultTier[_vault]; + require numTiers() < 100; + require tierId < numTiers(); + + requireInvariant tierReserveRatioGeThreshold(tierId); + require _VaultHub.reserveRatioBP(_vault) >= _VaultHub.forcedRebalanceThresholdBP(_vault); + } + preserved _OperatorGrid.changeTier( + address _vault, + uint256 _requestedTierId, + uint256 _requestedShareLimit + ) with (env e) { + requireInvariant tierReserveRatioGeThreshold(_requestedTierId); + } + preserved VaultHubHarness.connectVault(address _vault) with (env e) { + uint256 tierId = _OperatorGrid.og_storage.vaultTier[_vault]; + requireInvariant tierReserveRatioGeThreshold(tierId); + } + } + +// -- Misc Rules --------------------------------------------------------------- + + +/** +* @report: https://prover.certora.com/output/8195906/0c62dbd983f64b97bfac7696ae5545d4?anonymousKey=7fc0b9e06d4893ed529f0a1191c37b2ef5fdbbf6 +*/ +invariant redemptionSharesLeqLiabilityShares(address vault) + _VaultHub.redemptionShares(vault) <= _VaultHub.liabilityShares(vault) + filtered { + f -> f.contract == _VaultHub && !f.isView + } + +/** +* @report https://prover.certora.com/output/8195906/0c62dbd983f64b97bfac7696ae5545d4?anonymousKey=7fc0b9e06d4893ed529f0a1191c37b2ef5fdbbf6 +*/ +invariant pendingHasNoShares(address vault) + _VaultHub.isPendingDisconnect(vault) => ( + _VaultHub.liabilityShares(vault) == 0 && + _VaultHub.obligationsShares(vault) == 0 + ) + filtered { + f -> f.contract == _VaultHub && !f.isView + } + { + preserved _VaultHub.disconnect(address _vault) with (env _e) { + requireInvariant redemptionSharesLeqLiabilityShares(_vault); + } + preserved _VaultHub.voluntaryDisconnect(address _vault) with (env _e) { + requireInvariant redemptionSharesLeqLiabilityShares(_vault); + } + } + + + +/// @dev functions that can increase a vault's total value +definition isIncreasingTotal(method f) returns bool = ( + f.selector == sig:VaultHubHarness.fund(address).selector || + f.selector == sig:VaultHubHarness.applyVaultReport( + address, uint256, uint256, int256, uint256, uint256, uint256, uint256 + ).selector || + f.selector == sig:VaultHubHarness.connectVault(address).selector +); + +/// @title Which functions can increase a vault's total value +/// @notice Violated for `settleVaultObligations`. See +/// `https://github.com/lidofinance/core/issues/1298`. The violation occurs because +/// the total value becomes negative, and then unsafely cast to `uint`. +rule canIncreaseTotalValue(method f, address vault) filtered { + f -> f.contract == _VaultHub && !f.isView +} { + reasonableDeltaValues(vault); + require(_VaultHub.totalValue(vault) > 0, "Assume vault has non-zero value"); + requireInvariant pendingHasNoShares(vault); + uint256 valuePre = _VaultHub.totalValue(vault); + + env e; + calldataarg args; + f(e, args); + + uint256 valuePost = _VaultHub.totalValue(vault); + + assert( + valuePost > valuePre => isIncreasingTotal(f), + "Only specific functions can increase a vault's total value" + ); +} + + +/// @title Fees can only be increased by `applyVaultReport` +/// @notice Previously this rule referred to vault obligations and was violated, see below. +/// @notice Previously violated for the following functions, +/// see `https://github.com/lidofinance/core/issues/1321` +/// - `applyVaultReport` +/// - `resumeBeaconChainDeposits` +/// - `settleVaultObligations` +rule redemptionsIncrease(method f, address vault) filtered { + f -> f.contract == _VaultHub && !f.isView +} { + reasonableDeltaValues(vault); + requireInvariant pendingHasNoShares(vault); + uint256 feesPre = _VaultHub.unsettledLidoFees(vault); + + env e; + calldataarg args; + f(e, args); + + uint256 feesPost = _VaultHub.unsettledLidoFees(vault); + assert( + feesPost > feesPre => + f.selector == sig:VaultHubHarness.applyVaultReport( + address, uint256, uint256, int256, uint256, uint256, uint256, uint256 + ).selector, + "Only applyVaultReport can increase fees" + ); +} + +/* +/// @title Generate simple counter examples to `redemptionsIncrease` +rule redemptionsIncreaseViolation(method f, address vault) filtered { + f -> ( + f.selector == sig:VaultHubHarness.applyVaultReport( + address,uint256,uint256,int256,uint256,uint256,uint256,uint256 + ).selector || + f.selector == sig:VaultHubHarness.resumeBeaconChainDeposits(address).selector || + f.selector == sig:VaultHubHarness.settleVaultObligations(address).selector + ) +} { + reasonableDeltaValues(vault); + niceViolationRequirements(vault); + requireInvariant pendingHasNoShares(vault); + requireInvariant vaultLockedCoversLiabilityAndReserve(vault); + require( + _VaultHub.locked(vault) <= _VaultHub.totalValue(vault), + "Assume total value covers locked" + ); + requireInvariant vaultReserveRatioGeThreshold(vault); + uint128 redemptionsPre = _VaultHub.redemptions(vault); + + env e; + calldataarg args; + f(e, args); + + uint128 redemptionsPost = _VaultHub.redemptions(vault); + assert(redemptionsPost <= redemptionsPre); +} +*/ + + + + + + +// -- Violation examples ------------------------------------------------------- +/* +/// Example that `_rebalanceExternalEtherToInternal` can cause another vault to become unhealthy +//rule example + +function requireVaultTotalNotLessThanLocked(address vault) { + require ( + _VaultHub.isVaultConnected(vault) => ( + _VaultHub.totalValue(vault) >= _VaultHub.locked(vault) + ), "Assume vault locked is at most its total value" + ); +} + + +/// @title Example that `updateConnection` followed by `withdraw` may turn a vault unhealthy +rule healthViolationByTierChange( + address vault, + uint256 newTierId, + uint256 newShareLimit, + uint256 etherAmount +) { + reasonableDeltaValues(vault); + require( + _VaultHub.totalValue(vault) == 1000 && + _VaultHub.forcedRebalanceThresholdBP(vault) == 1000 && // 10% + tierforcedRebalanceThresholdBP(newTierId) == 2000, // 20% + "Assume small or simple values for simpler example" + ); + requireInvariant vaultReserveRatioGeThreshold(vault); + requireInvariant vaultLockedCoversLiabilityAndReserve(vault); + requireVaultTotalNotLessThanLocked(vault); + require(_VaultHub.isVaultHealthy(vault), "Pre condition - assume vault is healthy"); + require(CVLgetPooledEthByShares(1) >= 1, "Assume 1 share is more than 1 ETH"); + require(_VaultHub.isVaultConnected(vault), "Assume connected vault"); + requireInvariant tierReserveRatioGeThreshold(newTierId); + + env e; + require(e.msg.sender != vault); + + _OperatorGrid.changeTier(e, vault, newTierId, newShareLimit); + + bool intermediateHealth = _VaultHub.isVaultHealthy(vault); + + _VaultHub.withdraw(e, vault, e.msg.sender, etherAmount); + + assert _VaultHub.isVaultHealthy(vault); +} + + +/// @title An example that a healthy vault can turn unhealthy via calls to `_settleObligations` +rule healthViolationBySettling(address vault) { + reasonableDeltaValues(vault); + require( + _VaultHub.totalValue(vault) == 1000 && + _VaultHub.forcedRebalanceThresholdBP(vault) == 1000, // 10% + "Assume small or simple values for simpler example" + ); + require( + _internalShares() >= 100 * _VaultHub.liabilityShares(vault) && + _internalEth >= 100 * _VaultHub.totalValue(vault), + "Assume Lido holds many more shares and ETH than the vault" + ); + requireInvariant vaultLockedCoversLiabilityAndReserve(vault); + requireInvariant vaultReserveRatioGeThreshold(vault); + requireVaultTotalNotLessThanLocked(vault); + require(_VaultHub.isVaultHealthy(vault), "Pre condition - assume vault is healthy"); + //require(CVLgetPooledEthByShares(1) >= 1, "Assume 1 share is more than 1 ETH"); + require(_VaultHub.isVaultConnected(vault), "Assume connected vault"); + require(_VaultHub.isPendingDisconnect(vault), "Assume vault is pending disconnect"); + + env e1; + _VaultHub.voluntaryDisconnect(e1, vault); + + env e2; + _VaultHub.settleVaultObligations(e2, vault); + + satisfy !_VaultHub.isVaultHealthy(vault); +} + + +rule healthViolationByRebalancing(address vault, uint256 shares) { + reasonableDeltaValues(vault); + require( + _VaultHub.totalValue(vault) == 1000 && + _VaultHub.forcedRebalanceThresholdBP(vault) == 1000, // 10% + "Assume small or simple values for simpler example" + ); + require( + _internalShares() >= 100 * _VaultHub.liabilityShares(vault) && + _internalEth >= 100 * _VaultHub.totalValue(vault), + "Assume Lido holds many more shares and ETH than the vault" + ); + requireInvariant vaultLockedCoversLiabilityAndReserve(vault); + requireInvariant vaultReserveRatioGeThreshold(vault); + requireVaultTotalNotLessThanLocked(vault); + require(_VaultHub.isVaultHealthy(vault), "Pre condition - assume vault is healthy"); + require(CVLgetPooledEthByShares(1) >= 1, "Assume 1 share is more than 1 ETH"); + require(_VaultHub.isVaultConnected(vault), "Assume connected vault"); + + env e; + require( + e.msg.value <= maxReasonableValue(), + "Avoid overflow due to unreasonable ETH amount (e.g. in `VaultHub.fund`" + ); + //calldataarg args; + //f(e, args); + _VaultHub.rebalance(e, vault, shares); + + //assert _VaultHub.isVaultHealthy(vault); + assert( + CVLgetPooledEthBySharesRoundUp(_VaultHub.liabilityShares(vault)) <= + (_VaultHub.totalValue(vault) * 9000 / 10000) + 2 + ); +} +*/ diff --git a/certora/specs/vaults/VaultHub_health.spec b/certora/specs/vaults/VaultHub_health.spec new file mode 100644 index 0000000000..4e4f014c9f --- /dev/null +++ b/certora/specs/vaults/VaultHub_health.spec @@ -0,0 +1,222 @@ +import "./VaultHub.spec"; + +methods { + + function maxLiabilityShares(address) external returns uint96 envfree; + function minimalReserve(address) external returns uint128 envfree; + function redemptionShares(address) external returns uint128 envfree; + function _getPooledEthBySharesRoundUp(uint256 _shares) internal returns (uint256); + + function VaultHub._withdrawableValueFeesIncluded(address _vault, VaultHub.VaultConnection storage,VaultHub.VaultRecord storage) internal returns uint256 with (env e) => _withdrawableValueFeesIncludedCVL(e, _vault); + + function OperatorGrid.onBurnedShares(address _vault, uint256 _amount) external => NONDET; + +} + + + +definition min(mathint x, mathint y) returns mathint = + x > y ? y : x; + +definition max(mathint x, mathint y) returns mathint = + x > y ? x : y; + +function mulDivUpCVL(uint256 x, uint256 y, uint256 z) returns uint256 { + require z !=0; + return require_uint256((x * y + z - 1) / z); +} + +function _lockedCVL(env e, uint256 maxLiabilityShares, uint256 minimalReserve, uint256 reserveRatioBP) returns uint256 { + uint256 liability = _getPooledEthBySharesRoundUp(e, maxLiabilityShares); + + uint256 reserve = mulDivUpCVL(liability, reserveRatioBP, require_uint256(TOTAL_BASIS_POINTS() - reserveRatioBP)); + + return require_uint256(liability + max(reserve, minimalReserve)); +} + +function _unlockedCVL(env e, uint256 totalValue, uint256 maxLiabilityShares, uint256 minimalReserve, uint256 reserveRatioBP) returns (uint256) { + uint256 locked = _lockedCVL(e, maxLiabilityShares, minimalReserve, reserveRatioBP); + return totalValue > locked ? assert_uint256(totalValue - locked) : 0; +} + +function _withdrawableValueFeesIncludedCVL(env e, address vault) returns uint256 { + + int104 recordDelta = _VaultHub.getVaultRecordDeltaValue(vault); + int104 reportDelta = _VaultHub.getVaultReportDelta(vault); + uint104 reportTotalValue = getVaultReportTotal(vault); + + uint256 redemptionShares = redemptionShares(vault); + + uint256 balance = _availableBalance(e, vault); + + uint256 totalValue = require_uint256(reportTotalValue + recordDelta - reportDelta); + uint256 availableBalance = assert_uint256(min(balance, totalValue)); + + + uint256 redemptionValue = _getPooledEthBySharesRoundUp(e, redemptionShares); + if (redemptionValue > availableBalance) { + return 0; + } + uint256 availableBalanceWithoutRedemption = assert_uint256(availableBalance - redemptionValue); + + uint256 maxLiabilityShares = maxLiabilityShares(vault); + uint256 minimalReserve = minimalReserve(vault); + uint256 reserveRatioBP = reserveRatioBP(vault); + + // We must account vaults locked value when calculating the withdrawable amount + return assert_uint256(min(availableBalanceWithoutRedemption, _unlockedCVL(e, totalValue,maxLiabilityShares,minimalReserve,reserveRatioBP))); +} + +function requireSoundVaultState(address vault) { + requireInvariant vaultReserveRatioGeThreshold(vault); + // NOTE: This should be formalized as an invariant + require _VaultHub.vh_storage.records[vault].liabilityShares <= _VaultHub.vh_storage.records[vault].maxLiabilityShares; + require _VaultHub.vh_storage.connections[vault].reserveRatioBP > 0; + requireInvariant reserveRatioNotBig(vault); + reasonableDeltaValues(vault); + requireInvariant vaultLockedCoversLiabilityAndReserve(vault); +} + + + +/// @title A healthy vault remains healthy until a new report is produced, with the exception of settling fees. +/// @notice For `forceRebalance` we assume the vault has not redemption shares. +/// @notice Fails for rounding issues in `rebalance` nad `forceRebalance` - see `https://github.com/lidofinance/core/issues/1262`. +/// See: https://prover.certora.com/output/8195906/4e31567240ae4252b8733272ada97e2c?anonymousKey=68396f69922f0d2a85700e75e4219224358ef93a +/// Example +/// Initial: +/// - liabilityShares = 4 +/// - totalValue = 42 +/// - liability value = ceil(liabilityShares * 21 / 2) = 42 +/// - => Vault is healthy (totalValue = 42 = liabilityValue) +/// Rebalance 1 share, which is worth ceil(1 * 21/2) = ceil(10.5) = 11 eth +/// Afterwards we have: +/// - liabilityShares = 3 (1 rebalanced) +/// - totalValue = 31 (11 was rebalanced) +/// - liability (value) = ceil(liabilityShares * 21 / 2) = ceil( 3 * 21 / 2) = 32 +/// - => Vault is unhealthy (totalValue = 31 < 32 = liabilityValue) +/// If instead rebalancing rounded down (10 eth), we'd remain healthy (totalValue = 32 liabilityValue). +/// This issue is acknowledged, but fine. To filter out these kinds of violations, +// we assume that the threshold is not breached by at least 2 wei. +/// @dev The same type of violation was previously seen in other functions too (e.g. settle fees). These are now timing out. +/// @dev Running this requires applying the path `munge-VaultHub-health-constant.patch` +rule vaultIsHealtyhUntilReport(method f, address vault) filtered { + f -> ( + f.contract == _VaultHub && + !f.isView && + f.selector != sig:VaultHubHarness.applyVaultReport( + address, uint256, uint256, int256, uint256, uint256 ,uint256, uint256 + ).selector + ) +} { + requireSoundVaultState(vault); + require(_VaultHub.isVaultHealthy(vault), "Pre condition - assume vault is healthy"); + env e; + require( + e.msg.value <= maxReasonableValue(), + "Avoid overflow due to unreasonable ETH amount (e.g. in `VaultHub.fund`" + ); + if ( + f.selector == sig:VaultHubHarness.updateConnection( + address,uint256,uint256,uint256,uint256,uint256,uint256 + ).selector + ) { + // This case needs an additional requirement + uint256 _shareLimit; + uint256 _reserveRatioBP; + uint256 _forcedRebalanceThresholdBP; + uint256 _infraFeeBP; + uint256 _liquidityFeeBP; + uint256 _reservationFeeBP; + // NOTE: This is enforced by PredepositGuarantee, see reserveRatioNotGreaterThanThreshold + require( + _forcedRebalanceThresholdBP <= _reserveRatioBP, + "This is enforced by PredepositGuarantee, see reserveRatioNotGreaterThanThreshold" + ); + _VaultHub.updateConnection( + e, + vault, + _shareLimit, + _reserveRatioBP, + _forcedRebalanceThresholdBP, + _infraFeeBP, + _liquidityFeeBP, + _reservationFeeBP + ); + } else if (f.selector == sig:VaultHubHarness.withdraw(address,address,uint256).selector) { + address _recipient; + uint256 _ether; + _VaultHub.withdraw(e, vault, _recipient, _ether); + + } else if (f.selector == sig:VaultHubHarness.settleLidoFees(address /*vault*/).selector) { + _VaultHub.settleLidoFees(e, vault); + } else if (f.selector == sig:VaultHubHarness.rebalance(address,uint256).selector) { + uint256 _shares; + require(_internalShares() < _internalEth, "Assume a share price greater than 1"); + + uint256 tv = totalValue(vault); + uint256 ls = liabilityShares(vault); + uint256 tbp = forcedRebalanceThresholdBP(vault); + + require(CVLgetPooledEthBySharesRoundUp(ls) + 1 < tv * ((TOTAL_BASIS_POINTS() - tbp) / TOTAL_BASIS_POINTS()), "Prevent rounding issues"); + _VaultHub.rebalance(e, vault, _shares); + }else if (f.selector == sig:VaultHubHarness.forceRebalance(address).selector) { + require(_internalShares() < _internalEth, "Assume a share price greater than 1"); + + uint256 tv = totalValue(vault); + uint256 ls = liabilityShares(vault); + uint256 tbp = forcedRebalanceThresholdBP(vault); + + require(CVLgetPooledEthBySharesRoundUp(ls) + 1 < tv * ((TOTAL_BASIS_POINTS() - tbp) / TOTAL_BASIS_POINTS()), "Prevent rounding issues"); + + _VaultHub.forceRebalance(e, vault); + } else { + calldataarg args; + f(e, args); + } + + assert( + _VaultHub.isVaultHealthy(vault), + "A vault should remain healthy until a new report arrives" + ); +} + +/* +/// @dev This is for testing if the rounding issue occurs also when the value +/// of 1 share is 1 ETH or more. +/// @notice This is still violated due to the same rounding issues as above in +/// `vaultIsHealtyhUntilReport`. +rule vaultIsHealtyhUntilReportRatioMoreOne(method f, address vault) filtered { + f -> ( + f.selector == sig:VaultHubHarness.rebalance(address, uint256).selector || + f.selector == sig:VaultHubHarness.forceRebalance(address).selector + ) +} { + reasonableDeltaValues(vault); + requireInvariant vaultLockedCoversLiabilityAndReserve(vault); + require(CVLgetPooledEthByShares(1) >= 1, "Assume 1 share is more than 1 ETH"); + require(_VaultHub.isVaultHealthy(vault), "Pre condition - assume vault is healthy"); + + env e; + require( + e.msg.value <= maxReasonableValue(), + "Avoid overflow due to unreasonable ETH amount (e.g. in `VaultHub.fund`" + ); + calldataarg args; + f(e, args); + + assert( + _VaultHub.isVaultHealthy(vault), + "A vault should remain healthy until a new report arrives" + ); +} +*/ + + +/// Correctness of above summary in terms of functional equivalence. +/// Report: https://prover.certora.com/output/8195906/6a3f94fccfd142a3af5fbaa40fcca878/?anonymousKey=d4ca77cc9310d8aa47d18fa8bbf2febc31dc1dc7 +rule summaryCorrect(env e, address vault) { + uint256 original = withdrawableValueFeesIncluded(e,vault); + uint256 summary = _withdrawableValueFeesIncludedCVL(e, vault); + assert original == summary; +} diff --git a/certora/specs/vaults/approximated-VaultHub.spec b/certora/specs/vaults/approximated-VaultHub.spec new file mode 100644 index 0000000000..e7da71c557 --- /dev/null +++ b/certora/specs/vaults/approximated-VaultHub.spec @@ -0,0 +1,580 @@ + +/* `VaultHUb` properties + +We neglect the effects of rebalancing on internal-shares to internal-ETH ratio. + +NOTE: There is here an implicit assumption that the conversion ratio +(`getPooledEthByShares`) only changes by calls to `rebalanceExternalEtherToInternal`. +*/ +using VaultHubHarness as _VaultHub; +using PredepositGuarantee as _PredepositGuarantee; +using OperatorGrid as _OperatorGrid; +using ILidoMock as _Lido; + +methods { + // `LidoLocator` + function _.vaultHub() external => _VaultHub expect address; + function _.lido() external => _Lido expect address; + function _.operatorGrid() external => _OperatorGrid expect address; + function _.accounting() external => NONDET; + + // `ILidoMock` + function ILidoMock.mintExternalShares(address, uint256) external => NONDET; + function ILidoMock.burnExternalShares(uint256) external => NONDET; + function ILidoMock.getSharesByPooledEth( + uint256 _ethAmount + ) external returns (uint256) => CVLgetSharesByPooledEth(_ethAmount); + function ILidoMock.getPooledEthByShares( + uint256 _sharesAmount + ) external returns (uint256) => CVLgetPooledEthByShares(_sharesAmount); + function ILidoMock.getPooledEthBySharesRoundUp( + uint256 _sharesAmount + ) external returns (uint256) => CVLgetPooledEthBySharesRoundUp(_sharesAmount); + function ILidoMock.getTotalShares() external returns (uint256) => NONDET; + + // NOTE: We ignore any side-effects of `rebalanceExternalEtherToInternal` for simplification + function ILidoMock.rebalanceExternalEtherToInternal() external => NONDET; + + // NOTE: This summary may not be sound - it returns NONDET for simplification + function ILidoMock.transferSharesFrom( + address, address, uint256 + ) external returns (uint256) => NONDET; + + + // `VaultHubHarness` + function VaultHubHarness.totalValue(address) external returns (uint256) envfree; + function VaultHubHarness.locked(address) external returns (uint256) envfree; + function VaultHubHarness.isPendingDisconnect(address) external returns (bool) envfree; + function VaultHubHarness.isVaultConnected(address) external returns (bool) envfree; + function VaultHubHarness.isVaultHealthy(address) external returns (bool) envfree; + function VaultHubHarness.getVaultRecordDeltaValue(address) external returns (int104) envfree; + function VaultHubHarness.getVaultReportDelta(address) external returns (int104) envfree; + function VaultHubHarness.getVaultReportTotal(address) external returns (uint104) envfree; + function VaultHubHarness.unsettledLidoFees(address) external returns (uint256) envfree; + function VaultHubHarness.liabilityShares(address) external returns (uint256) envfree; + function VaultHubHarness.badDebtToInternalize() external returns (uint256) envfree; + function VaultHubHarness.vaultsArrayLength() external returns (uint256) envfree; + function VaultHubHarness.vaultArrayAtIndex(uint256) external returns (address) envfree; + function VaultHubHarness.getInitializedVersion() external returns (uint64) envfree; + function VaultHubHarness.vaultConnection( + address + )external returns (VaultHub.VaultConnection) envfree; + function VaultHubHarness.reserveRatioBP(address) external returns (uint16) envfree; + function VaultHubHarness.forcedRebalanceThresholdBP(address) external returns (uint16) envfree; + + // `LazyOracle` + function _.latestReportTimestamp() external => NONDET; + + // `StakingVault` + // Without the following summary, the call from `VaultHub`:Line 1071, + // `_predepositGuarantee().proveUnknownValidator(_witness, IStakingVault(_vault))`, + // becomes unresolved ("callee contract unresolved"). + function _.withdraw(address, uint256) external => DISPATCHER(true); + + function _.beaconChainDepositsPaused() external => DISPATCHER(true); + function _.resumeBeaconChainDeposits() external => DISPATCHER(true); + function _.pauseBeaconChainDeposits() external => DISPATCHER(true); + function _.transferOwnership(address) external => DISPATCHER(true); + function _.pendingOwner() external => DISPATCHER(true); + function _.depositor() external => DISPATCHER(true); + function _.owner() external => DISPATCHER(true); + function _.nodeOperator() external => DISPATCHER(true); + function _.acceptOwnership() external => DISPATCHER(true); + function _.fund() external => DISPATCHER(true); + function _.requestValidatorExit(bytes) external => DISPATCHER(true); + function _.triggerValidatorWithdrawals(bytes, uint64[], address) external => DISPATCHER(true); + + // Summarize the call to `WITHDRAWAL_REQUEST` in `TriggerableWithdrawals` library + // as `NONDET`. NOTE: This is not sound but necessary for analysis. + unresolved external in StakingVault.triggerValidatorWithdrawals( + bytes, uint64[], address + ) => DISPATCH [] default NONDET; + + // `OperatorGrid` + function OperatorGrid.tier(uint256) external returns (OperatorGrid.Tier) envfree; + + // `PredepositGuarantee` + // Without the following summary, the call from `VaultHub`:Line 929, + // `_predepositGuarantee().proveUnknownValidator(_witness, IStakingVault(_vault))`, + // becomes unresolved ("callee contract unresolved"). + function _.proveUnknownValidator( + IPredepositGuarantee.ValidatorWitness, address + ) external => DISPATCHER(true); + + // `BLS` Library + // Summarizing the `BLS` library since the Prover cannot easily handle such + // calculations and it contains many unsafe memory operations that hurt static + // analysis. Using NONDET as it's the most practical approach for verification. + function BLS12_381.verifyDepositMessage( + bytes calldata, + bytes calldata, + uint256, + BLS12_381.DepositY calldata, + bytes32, + bytes32 + ) internal => NONDET; + function BLS12_381.sha256Pair(bytes32, bytes32) internal returns (bytes32) => NONDET; + function BLS12_381.pubkeyRoot(bytes calldata) internal returns (bytes32) => NONDET; + + // `SSZ` Library + // NOTE: Summarized as NONDET due to complexity of SSZ operations + function SSZ.hashTreeRoot(SSZ.BeaconBlockHeader memory) internal returns (bytes32) => NONDET; + function SSZ.hashTreeRoot(SSZ.Validator memory) internal returns (bytes32) => NONDET; + function SSZ.verifyProof(bytes32[] calldata, bytes32, bytes32, SSZ.GIndex) internal => NONDET; + + // `CLProofVerifier` + // NOTE: Summarized as NONDET due to complexity of proof verification + function CLProofVerifier._validatePubKeyWCProof( + IPredepositGuarantee.ValidatorWitness calldata, + bytes32 + ) internal => NONDET; +} + +// -- Summary ghosts and functions --------------------------------------------- + + +ghost fullEthBySharesUp( + mathint, // internal shares + mathint, // internal ETH + mathint // shares amount +) returns mathint { + axiom forall mathint iShares. ( + forall mathint iEth. ( + forall mathint shares. ( + forall mathint rebalanced. ( + fullEthBySharesUp(iShares, iEth, shares - rebalanced) <= + fullEthBySharesUp(iShares, iEth, shares) - + fullEthBySharesUp(iShares, iEth, rebalanced) + 2 + ) + ) + ) + ); + // The effect of rebalancing on the internal-ETH to internal-shares ratio. + axiom forall mathint iShares. ( + forall mathint iEth. ( + forall mathint shares. ( + forall mathint rS. ( // Amount that has been rebalanced + fullEthBySharesUp( + iShares + rS, + iEth + fullEthBySharesUp(iShares, iEth, rS), + shares + ) - fullEthBySharesUp(iShares, iEth, shares) <= shares + ) + ) + ) + ); +} + + +ghost ethBySharesUp(mathint /* shares */ ) returns mathint { + axiom ethBySharesUp(0) == 0; + axiom forall mathint shares. forall mathint part. ( + shares > part => ( + ethBySharesUp(shares - part) <= ethBySharesUp(shares) - ethBySharesUp(part) + 2 + ) + ); + axiom forall mathint shares1. forall mathint shares2. ( + shares1 >= shares2 => ethBySharesUp(shares1) >= ethBySharesUp(shares2) + ); +} + + +ghost sharesByEth(mathint /* ETH */ ) returns mathint { + axiom sharesByEth(0) == 0; + axiom forall mathint eth. ( + ethBySharesUp(sharesByEth(eth)) >= eth && + ethBySharesUp(sharesByEth(eth)) <= eth + 1 + ); + axiom forall mathint eth1. forall mathint eth2. ( + eth1 >= eth2 => ethBySharesUp(eth1) >= ethBySharesUp(eth2) + ); +} + + + +/// @dev Summarizes `Lido.getSharesByPooledEth` +/// @notice While the original function will revert if `_ethAmount` exceeds `UINT128_MAX`, +/// this summary will not. +function CVLgetSharesByPooledEth(uint256 _ethAmount) returns uint256 { + require( + _ethAmount <= max_uint128, + "Lido.getSharesByPooledEth reverts if _ethAmount is bigger" + ); + return require_uint256(sharesByEth(_ethAmount)); +} + + +/// @dev Summarizes `Lido.getPooledEthBySharesRoundUp` +/// @notice While the original function will revert if `_sharesAmount` exceeds `UINT128_MAX`, +/// this summary will not. +function CVLgetPooledEthBySharesRoundUp(uint256 _sharesAmount) returns uint256 { + require( + _sharesAmount <= max_uint128, + "Lido.getPooledEthBySharesRoundUp reverts if _sharesAmount is bigger" + ); + return require_uint256(ethBySharesUp(_sharesAmount)); +} + + +/// @dev Summarizes `Lido.getPooledEthByShares` +/// @notice While the original function will revert if `_sharesAmount` exceeds `UINT128_MAX`, +/// this summary will not. +function CVLgetPooledEthByShares(uint256 _sharesAmount) returns uint256 { + require( + _sharesAmount <= max_uint128, + "Lido.getPooledEthBySharesRoundUp reverts if _sharesAmount is bigger" + ); + uint256 roundedUp = CVLgetPooledEthBySharesRoundUp(_sharesAmount); + uint256 eth; + require eth <= roundedUp && eth + 1 >= roundedUp; + return eth; +} + +// -- Property: vaults array is a set ------------------------------------------ + +/// @title A vault that is pending disconnect is connected +invariant disconnectedVaultIsNotPending(address vault) + _VaultHub.isPendingDisconnect(vault) => _VaultHub.isVaultConnected(vault) + filtered { + f -> f.contract == _VaultHub // `VaultHub` is sufficient for this invariant + } + { + preserved _VaultHub.applyVaultReport( + address _other, + uint256 _reportTimestamp, + uint256 _reportTotalValue, + int256 _reportInOutDelta, + uint256 _reportCumulativeLidoFees, + uint256 _reportLiabilityShares, + uint256 _reportMaxLiabilityShares, + uint256 _reportSlashingReserve + ) with (env e) { + requireInvariant disconnectedVaultIsNotPending(_other); + require( + _VaultHub.vaultArrayAtIndex(0) == 0, + "See vaultsArrayIsNeverEmpty, assumes contract is initialized" + ); + } + } + + +/// @title The `vaults` array in `VaultHub` has address 0 at index 0 after initialization +invariant vaultsArrayIsNeverEmpty() + (vaultIndex(0) == 0) && (isInitialized() => (vaultsLength() > 0 && vaults(0) == 0)) + filtered { + f -> f.contract == _VaultHub // `VaultHub` is sufficient for this invariant + } + { + preserved { + require( + isInitialized(), + "Assumes `initialize` is called immediately after constructor" + ); + } + preserved initialize(address _admin) with (env e) { + require( + _VaultHub.vaultsArrayLength() == 0, + "Assumes `initialize` is called immediately after constructor" + ); + } + preserved _VaultHub.applyVaultReport( + address _other, + uint256 _reportTimestamp, + uint256 _reportTotalValue, + int256 _reportInOutDelta, + uint256 _reportCumulativeLidoFees, + uint256 _reportLiabilityShares, + uint256 _reportMaxLiabilityShares, + uint256 _reportSlashingReserve + ) with (env e) { + applyVaultReportRquirements(_other); + } + } + + +invariant indexToVaultIsCorrect(uint96 index) + index < vaultsLength() => (vaultIndex(vaults(index)) == index) + filtered { + f -> ( + f.contract == _VaultHub && // `VaultHub` is sufficient for this invariant + // NOTE: Filtering out `initialize` as it's a special case handled separately + f.selector != sig:VaultHubHarness.initialize(address).selector + ) + } + { + /* + preserved { + require( + isInitialized(), + "Assumes `initialize` is called immediately after constructor" + ); + requireInvariant vaultsArrayIsNeverEmpty(); + } + */ + preserved _VaultHub.connectVault(address _other) with (env e) { + connectVaultRequirements(); + } + preserved _VaultHub.applyVaultReport( + address _other, + uint256 _reportTimestamp, + uint256 _reportTotalValue, + int256 _reportInOutDelta, + uint256 _reportCumulativeLidoFees, + uint256 _reportLiabilityShares, + uint256 _reportMaxLiabilityShares, + uint256 _reportSlashingReserve + ) with (env e) { + applyVaultReportRquirements(_other); + } + } + + +invariant vaultToIndexIsCorrect(address vault) + ( + (vaultIndex(vault) <= vaultsLength()) && + (vaultsLength() > 0 => vaultIndex(vault) < vaultsLength()) && + (vaultIndex(vault) > 0 => vaults(vaultIndex(vault)) == vault) + ) + filtered { + f -> ( + f.contract == _VaultHub && // `VaultHub` is sufficient for this invariant + // NOTE: Filtering out `initialize` as it's a special case handled separately + f.selector != sig:VaultHubHarness.initialize(address).selector + ) + } + { + /* + preserved { + require( + isInitialized(), + "Assumes `initialize` is called immediately after constructor" + ); + requireInvariant vaultsArrayIsNeverEmpty(); + } + */ + preserved _VaultHub.connectVault(address _other) with (env e) { + connectVaultRequirements(); + } + preserved _VaultHub.applyVaultReport( + address _other, + uint256 _reportTimestamp, + uint256 _reportTotalValue, + int256 _reportInOutDelta, + uint256 _reportCumulativeLidoFees, + uint256 _reportLiabilityShares, + uint256 _reportMaxLiabilityShares, + uint256 _reportSlashingReserve + ) with (env e) { + applyVaultReportRquirements(_other); + } + } + +// -- Utility functions -------------------------------------------------------- + +definition maxReasonableValue() returns mathint = 2^100; + + +/// @dev Sets limits on the vault's possible values +/// @notice These limits are set to prevent overflow/underflow and ensure reasonable values +function reasonableDeltaValues(address vault) { + int112 recordDelta = _VaultHub.getVaultRecordDeltaValue(vault); + require (recordDelta <= maxReasonableValue()) && (recordDelta >= -maxReasonableValue()); + + int112 reportDelta = _VaultHub.getVaultReportDelta(vault); + require (reportDelta <= maxReasonableValue()) && (reportDelta >= -maxReasonableValue()); + + uint112 reportTot = _VaultHub.getVaultReportTotal(vault); + require (reportTot <= maxReasonableValue()); + + mathint totValue = reportTot + recordDelta - reportDelta; + require totValue >= 0; + + mathint totRef = reportTot - reportDelta; + require totRef >= 0; +} + + +/// @dev Requirements that are needed in invariants for `_VaultHub.applyVaultReport`. +/// These are needed to prevent the case where a vault with index 0 is deleted +/// and therefore another becomes disconnected. +/// @notice Assumes `initialize` is called immediately after constructor (verified with Lido) +function applyVaultReportRquirements(address _other) { + require( + isInitialized(), + "Assumes `initialize` is called immediately after constructor" + ); + requireInvariant vaultsArrayIsNeverEmpty(); + + // In case `_other` is deleted + requireInvariant disconnectedVaultIsNotPending(_other); // So its index is not 0 + requireInvariant vaultToIndexIsCorrect(_other); + + // NOTE: This limits the number of vaults to `max_uint96` + uint96 lastIndex = require_uint96(vaultsLength() - 1); + address lastVault = vaults(lastIndex); + requireInvariant vaultToIndexIsCorrect(lastVault); + requireInvariant indexToVaultIsCorrect(lastIndex); +} + + +/// @dev Requirements that are needed in invariants for `_VaultHub.connectVault`. +/// These are needed to prevent a newly connected vault from being in index 0 and +/// therefore disconnected. +/// @notice Assumes `initialize` is called immediately after constructor (verified with Lido) +function connectVaultRequirements() { + require( + isInitialized(), + "Assumes `initialize` is called immediately after constructor" + ); + requireInvariant vaultsArrayIsNeverEmpty(); +} + + +/// @dev Returns whether the `VaultHub` has been initialized +definition isInitialized() returns bool = _VaultHub.getInitializedVersion() == 1; + +definition vaultsLength() returns uint256 = _VaultHub.vh_storage.vaults.length; + +definition vaults(uint96 index) returns address = ( + _VaultHub.vh_storage.vaults[assert_uint256(index)] +); + +definition vaultIndex(address vault) returns uint96 = ( + _VaultHub.vh_storage.connections[vault].vaultIndex +); + +/// @dev The same as `VaultHub.TOTAL_BASIS_POINTS` +definition TOTAL_BASIS_POINTS() returns uint256 = 10000; + + +definition isAlmostHealthy(address vault) returns bool = ( + ethBySharesUp(_VaultHub.liabilityShares(vault)) <= + ( + _VaultHub.totalValue(vault) * + (TOTAL_BASIS_POINTS() - _VaultHub.forcedRebalanceThresholdBP(vault)) / + TOTAL_BASIS_POINTS() + ) + 2 +); + + +// -- Invariants --------------------------------------------------------------- + +/// @title A vaults reserve ratio is at most 100% +invariant reserveRatioNotBig(address vault) + _VaultHub.reserveRatioBP(vault) <= TOTAL_BASIS_POINTS(); + + +/// @dev Returns the value n ETH of a vaults liability shares (rounded up) +definition liabilityEth(address vault) returns uint256 = ( + CVLgetPooledEthBySharesRoundUp(_VaultHub.liabilityShares(vault)) +); + +/// @title The locked amount of a vault +invariant vaultLockedCoversLiabilityAndReserve(address vault) + _VaultHub.locked(vault) * (TOTAL_BASIS_POINTS() - _VaultHub.reserveRatioBP(vault)) >= + liabilityEth(vault) * TOTAL_BASIS_POINTS() + { + preserved { + requireInvariant reserveRatioNotBig(vault); + requireInvariant vaultReserveRatioNotGreaterThanThreshold(vault); + } + preserved _VaultHub.applyVaultReport( + address _other, + uint256 _reportTimestamp, + uint256 _reportTotalValue, + int256 _reportInOutDelta, + uint256 _reportCumulativeLidoFees, + uint256 _reportLiabilityShares, + uint256 _reportMaxLiabilityShares, + uint256 _reportSlashingReserve + ) with (env e) { + applyVaultReportRquirements(_other); + requireInvariant reserveRatioNotBig(vault); + requireInvariant vaultReserveRatioNotGreaterThanThreshold(vault); + } + preserved _OperatorGrid.changeTier( + address _vault, uint256 _requestedTierId, uint256 _requestedShareLimit + ) with (env e) { + requireInvariant reserveRatioNotGreaterThanThreshold(_requestedTierId); + } + } + + +invariant vaultReserveRatioNotGreaterThanThreshold(address vault) + _VaultHub.reserveRatioBP(vault) >= _VaultHub.forcedRebalanceThresholdBP(vault); + +definition tierReserveRatioBP(uint256 tierId) returns uint16 = ( + _OperatorGrid.tier(tierId).reserveRatioBP +); + +definition tierforcedRebalanceThresholdBP(uint256 tierId) returns uint16 = ( + _OperatorGrid.tier(tierId).forcedRebalanceThresholdBP +); + +invariant reserveRatioNotGreaterThanThreshold(uint256 tierId) + tierReserveRatioBP(tierId) >= tierforcedRebalanceThresholdBP(tierId) + filtered { f -> f.contract == _OperatorGrid } + + +// -- Rules -------------------------------------------------------------------- + + +/// @title A healthy vault remains healthy until a new report is produced +rule vaultIsHealtyhUntilReport(method f, address vault) filtered { + f -> ( + f.contract == _VaultHub && + !f.isView && + f.selector != sig:VaultHubHarness.applyVaultReport( + address, uint256, uint256, int256, uint256, uint256 ,uint256, uint256 + ).selector + ) +} { + reasonableDeltaValues(vault); + requireInvariant vaultLockedCoversLiabilityAndReserve(vault); + requireInvariant vaultReserveRatioNotGreaterThanThreshold(vault); + //requireInvariant vaultTotalNotLessThanLocked(vault); + require(_VaultHub.isVaultHealthy(vault), "Pre condition - assume vault is healthy"); + + env e; + require( + e.msg.value <= maxReasonableValue(), + "Avoid overflow due to unreasonable ETH amount (e.g. in `VaultHub.fund`" + ); + if ( + f.selector == sig:VaultHubHarness.updateConnection( + address,uint256,uint256,uint256,uint256,uint256,uint256 + ).selector + ) { + // This case needs an additional requirement + address _vault; + uint256 _shareLimit; + uint256 _reserveRatioBP; + uint256 _forcedRebalanceThresholdBP; + uint256 _infraFeeBP; + uint256 _liquidityFeeBP; + uint256 _reservationFeeBP; + // NOTE: This is enforced by PredepositGuarantee, see reserveRatioNotGreaterThanThreshold + require( + _forcedRebalanceThresholdBP <= _reserveRatioBP, + "This is enforced by PredepositGuarantee, see reserveRatioNotGreaterThanThreshold" + ); + _VaultHub.updateConnection( + e, + _vault, + _shareLimit, + _reserveRatioBP, + _forcedRebalanceThresholdBP, + _infraFeeBP, + _liquidityFeeBP, + _reservationFeeBP + ); + } else { + calldataarg args; + f(e, args); + } + + assert( + isAlmostHealthy(vault), + "A vault should remain almost healthy until a new report arrives" + ); +} diff --git a/certora/specs/vaults/immutable-ratio.spec b/certora/specs/vaults/immutable-ratio.spec new file mode 100644 index 0000000000..9126c03bf8 --- /dev/null +++ b/certora/specs/vaults/immutable-ratio.spec @@ -0,0 +1,289 @@ +/* `VaultHUb` properties + +NOTE: This spec assumes the conversion rate of shares to ETH is CONSTANT!. +*/ + +import "./vaults-array.spec"; + +// `using VaultHubHarness as _VaultHub;` defined in `vaults-array.spec` +using PredepositGuarantee as _PredepositGuarantee; +using OperatorGrid as _OperatorGrid; +using ILidoMock as _Lido; + +methods { + // `LidoLocator` + function _.vaultHub() external => _VaultHub expect address; + function _.lido() external => _Lido expect address; + function _.operatorGrid() external => _OperatorGrid expect address; + function _.accounting() external => NONDET; + + // `ILidoMock` + function ILidoMock.mintExternalShares( + address _recipient, uint256 _amountOfShares + ) external => CVLmintExternalSharesImm(_amountOfShares); + function ILidoMock.burnExternalShares( + uint256 _amountOfShares + ) external => CVLburnExternalSharesImm(_amountOfShares); + function ILidoMock.getSharesByPooledEth( + uint256 _ethAmount + ) external returns (uint256) => CVLgetSharesByPooledEthImm(_ethAmount); + function ILidoMock.getPooledEthByShares( + uint256 _sharesAmount + ) external returns (uint256) => CVLgetPooledEthBySharesImm(_sharesAmount); + function ILidoMock.getPooledEthBySharesRoundUp( + uint256 _sharesAmount + ) external returns (uint256) => CVLgetPooledEthBySharesRoundUpImm(_sharesAmount); + function ILidoMock.rebalanceExternalEtherToInternal( + ) external with (env e) => CVLrebalanceExternalEtherToInternalImm(e.msg.value); + function ILidoMock.getTotalShares() external returns (uint256) => NONDET; + + // NOTE: This summary may not be sound - it returns NONDET for simplification + function ILidoMock.transferSharesFrom( + address, address, uint256 + ) external returns (uint256) => NONDET; + + // `LazyOracle` + function _.latestReportTimestamp() external => NONDET; + + // `StakingVault` + // Without the following summary, the call from `VaultHub`:Line 1071, + // `_predepositGuarantee().proveUnknownValidator(_witness, IStakingVault(_vault))`, + // becomes unresolved ("callee contract unresolved"). + function _.withdraw(address, uint256) external => DISPATCHER(true); + + function _.beaconChainDepositsPaused() external => DISPATCHER(true); + function _.resumeBeaconChainDeposits() external => DISPATCHER(true); + function _.pauseBeaconChainDeposits() external => DISPATCHER(true); + function _.transferOwnership(address) external => DISPATCHER(true); + function _.pendingOwner() external => DISPATCHER(true); + function _.availableBalance() external => DISPATCHER(true); + function _.depositor() external => DISPATCHER(true); + function _.owner() external => DISPATCHER(true); + function _.nodeOperator() external => DISPATCHER(true); + function _.acceptOwnership() external => DISPATCHER(true); + function _.fund() external => DISPATCHER(true); + function _.requestValidatorExit(bytes) external => DISPATCHER(true); + function _.triggerValidatorWithdrawals(bytes, uint64[], address) external => DISPATCHER(true); + + // Summarize the call to `WITHDRAWAL_REQUEST` in `TriggerableWithdrawals` library + // as `NONDET`. NOTE: This is not sound but necessary for analysis. + unresolved external in StakingVault.triggerValidatorWithdrawals( + bytes, uint64[], address + ) => DISPATCH [] default NONDET; + + // `OperatorGrid` + function OperatorGrid.tier(uint256) external returns (OperatorGrid.Tier) envfree; + function OperatorGrid.tiersCount() external returns (uint256) envfree; + + // `PredepositGuarantee` + // Without the following summary, the call from `VaultHub`:Line 929, + // `_predepositGuarantee().proveUnknownValidator(_witness, IStakingVault(_vault))`, + // becomes unresolved ("callee contract unresolved"). + function _.proveUnknownValidator( + IPredepositGuarantee.ValidatorWitness, address + ) external => DISPATCHER(true); + + // `BLS` Library + // Summarizing the `BLS` library since the Prover cannot easily handle such + // calculations and it contains many unsafe memory operations that hurt static + // analysis. Using NONDET as it's the most practical approach for verification. + function BLS12_381.verifyDepositMessage( + bytes calldata, + bytes calldata, + uint256, + BLS12_381.DepositY calldata, + bytes32, + bytes32 + ) internal => NONDET; + function BLS12_381.sha256Pair(bytes32, bytes32) internal returns (bytes32) => NONDET; + function BLS12_381.pubkeyRoot(bytes calldata) internal returns (bytes32) => NONDET; + + // `SSZ` Library + // NOTE: Summarized as NONDET due to complexity of SSZ operations + function SSZ.hashTreeRoot(SSZ.BeaconBlockHeader memory) internal returns (bytes32) => NONDET; + function SSZ.hashTreeRoot(SSZ.Validator memory) internal returns (bytes32) => NONDET; + function SSZ.verifyProof(bytes32[] calldata, bytes32, bytes32, SSZ.GIndex) internal => NONDET; + + // `CLProofVerifier` + // NOTE: Using wildcard and NONDET as the Prover cannot resolve CLProofVerifier + // (it worked in previous versions of the code `d1b4b34ebc911f01aca285d8d7b758f8c5fc7619`) + function _._validatePubKeyWCProof( + IPredepositGuarantee.ValidatorWitness calldata, + bytes32 + ) internal => NONDET; +} + +// -- Summary ghosts and functions --------------------------------------------- + +ghost uint256 _internalShares { + // NOTE: Internal shares must be positive and bounded by uint128 + // Positivity is required to avoid division by zero + axiom _internalShares > 0 && _internalShares <= max_uint128; +} + + +ghost uint256 _internalEthGhost { + // NOTE: Internal ETH must be positive and bounded by uint128 + // The positivity requirement is an assumption to avoid division by zero + axiom _internalEthGhost > 0 && _internalEthGhost <= max_uint128; +} + + +/// @dev Summarizes `Lido.getSharesByPooledEth` +/// @notice While the original function will revert if `_ethAmount` exceeds `UINT128_MAX`, +/// this summary will not. +function CVLgetSharesByPooledEthImm(uint256 _ethAmount) returns uint256 { + require( + _ethAmount <= max_uint128, + "Lido.getSharesByPooledEth reverts if _ethAmount is bigger" + ); + uint256 numeratorInEther = _internalEthGhost; + uint256 denominatorInShares = _internalShares; + return require_uint256((_ethAmount * denominatorInShares) / numeratorInEther); +} + + +/// @dev Summarizes `Lido.getPooledEthBySharesRoundUp` +/// @notice While the original function will revert if `_sharesAmount` exceeds `UINT128_MAX`, +/// this summary will not. +function CVLgetPooledEthBySharesRoundUpImm(uint256 _sharesAmount) returns uint256 { + require( + _sharesAmount <= max_uint128, + "Lido.getPooledEthBySharesRoundUp reverts if _sharesAmount is bigger" + ); + uint256 numeratorInEther = _internalEthGhost; + uint256 denominatorInShares = _internalShares; + + return assert_uint256( + // Add `denominatorInShares - 1` to round up + (_sharesAmount * numeratorInEther + denominatorInShares - 1) + / denominatorInShares + ); +} + + +/// @dev Summarizes `Lido.getPooledEthByShares` +/// @notice While the original function will revert if `_sharesAmount` exceeds `UINT128_MAX`, +/// this summary will not. +function CVLgetPooledEthBySharesImm(uint256 _sharesAmount) returns uint256 { + require( + _sharesAmount <= max_uint128, + "Lido.getPooledEthBySharesRoundUp reverts if _sharesAmount is bigger" + ); + uint256 numeratorInEther = _internalEthGhost; + uint256 denominatorInShares = _internalShares; + + return assert_uint256( + (_sharesAmount * numeratorInEther) / denominatorInShares + ); +} + + +/// @dev Summarizes `Lido.mintExternalShares` +/// @notice While the original function will revert if either `_recipient` or +/// `_amountOfShares` is zero, or `_amountOfShares` is too high, this summary will not. +/// @notice This summary does nothing! +function CVLmintExternalSharesImm(uint256 _amountOfShares) { +} + + +/// @dev Summarizes `Lido.burnExternalShares` +/// @notice While the original function will revert if `_amountOfShares` is zero +/// or too large this summary will not. +/// @notice This summary does nothing! +function CVLburnExternalSharesImm(uint256 _amountOfShares) { +} + + +/// @dev Summarizes `Lido.rebalanceExternalEtherToInternal` +/// @notice While the original function will revert if `msg_value` is zero or too large +/// this summary will not. +/// @notice This summary does nothing! +function CVLrebalanceExternalEtherToInternalImm(uint256 msg_value) { +} + +// -- Utility functions -------------------------------------------------------- + +/// @dev The same as `VaultHub.TOTAL_BASIS_POINTS` +definition TOTAL_BASIS_POINTS() returns uint256 = 10000; + +// -- Invariants --------------------------------------------------------------- + +definition numTiers() returns uint256 = _OperatorGrid.tiersCount(); + +/// @dev Using this definition, otherwise pushing a new tier will be ignored +definition tierReserveRatioBP(uint256 tierId) returns uint16 = ( + tierId < numTiers() ? _OperatorGrid.tier(tierId).reserveRatioBP : 0 +); + +/// @dev Using this definition, otherwise pushing a new tier will be ignored +definition tierforcedRebalanceThresholdBP(uint256 tierId) returns uint16 = ( + tierId < numTiers() ? _OperatorGrid.tier(tierId).forcedRebalanceThresholdBP : 0 +); + +/// @title For each tier the reserve ratio is greater than the force rebalance threshold +/// @dev Disabled here! See `VaultHub.spec`. +/// @notice Violated because the `initialize` function does not validate the parameters +/// (i.e. it does not call `_validateParams`). +/// See `https://github.com/lidofinance/core/issues/1291`. +invariant reserveRatioNotGreaterThanThreshold(uint256 tierId) + tierReserveRatioBP(tierId) >= tierforcedRebalanceThresholdBP(tierId) + filtered { f -> f.contract == _OperatorGrid } + + +/// @title For every vault its reserve ratio is greater than its force rebalance threshold +invariant vaultReserveRatioNotGreaterThanThreshold(address vault) + _VaultHub.reserveRatioBP(vault) >= _VaultHub.forcedRebalanceThresholdBP(vault) + filtered { + f -> ( + f.contract == _OperatorGrid || + ( + f.contract == _VaultHub && + // This function is only called by the `OperatorGrid` + f.selector != sig:VaultHubHarness.updateConnection( + address, uint256, uint256, uint256, uint256, uint256, uint256 + ).selector + ) + ) + } + { + preserved OperatorGrid.changeTier( + address _vault, + uint256 _requestedTierId, + uint256 _requestedShareLimit + ) with (env e) { + requireInvariant reserveRatioNotGreaterThanThreshold(_requestedTierId); + } + } + +/// @dev Returns the value n ETH of a vaults liability shares (rounded up) +definition liabilityEth(address vault) returns uint256 = ( + CVLgetPooledEthBySharesRoundUpImm(_VaultHub.liabilityShares(vault)) +); + + +/// @title The locked amount of a vault covers its shares and reserve with immutable ratio +/// @dev This is needed just to verify that the violations of +/// `vaultLockedCoversLiabilityAndReserve` in the relevant functions are due to the +/// shares to ETH ratio changes. +/// @dev To prevent timeouts, this rule needs to be run for each of the three methods individually +invariant vaultLockedCoversLiabilityAndReserveImmutableRatio(address vault) + (_VaultHub.reserveRatioBP(vault) < TOTAL_BASIS_POINTS()) => ( + _VaultHub.locked(vault) >= ( + liabilityEth(vault) * TOTAL_BASIS_POINTS() / + (TOTAL_BASIS_POINTS() - _VaultHub.reserveRatioBP(vault)) + ) + ) + filtered { + f -> ( + f.selector == sig:VaultHubHarness.rebalance(address, uint256).selector || + f.selector == sig:VaultHubHarness.forceRebalance(address).selector || + f.selector == sig:VaultHubHarness.resumeBeaconChainDeposits(address).selector + ) + } + { + preserved { + requireInvariant vaultReserveRatioNotGreaterThanThreshold(vault); + require(_internalShares > 0 && _internalEthGhost > 0, "Avoid division by zero"); + } + } diff --git a/certora/specs/vaults/lazy-oracle.spec b/certora/specs/vaults/lazy-oracle.spec new file mode 100644 index 0000000000..2282d43721 --- /dev/null +++ b/certora/specs/vaults/lazy-oracle.spec @@ -0,0 +1,304 @@ +/* Spec for the `LazyOracle` contract */ +// NOTE: LazyOracle uses an internal `enum QuarantineState` for determining quarantine status. +// The rules in this spec verify quarantine behavior through the QuarantineInfo struct fields +// (isActive, startTimestamp, endTimestamp) which capture all relevant states. + +import "./vaults-array.spec"; +import "./lido-mock.spec"; + +using LazyOracleHarness as _LazyOracle; +using OperatorGrid as _OperatorGrid; +using ILidoMock as _Lido; + +methods { + // `LidoLocator` + function _.vaultHub() external => _VaultHub expect address; + function _.lido() external => _Lido expect address; + function _.operatorGrid() external => _OperatorGrid expect address; + function _.accounting() external => CONSTANT; + + // `VaultHub` + function _.applyVaultReport( + address, uint256, uint256, int256, uint256, uint256, uint256, uint256 + ) external => DISPATCHER(true); + + // `LazyOracleHarness` + function LazyOracleHarness.quarantinePeriod() external returns (uint256) envfree; + function LazyOracleHarness.vaultQuarantine( + address + ) external returns (LazyOracle.QuarantineInfo) envfree; + function LazyOracleHarness.vaultQuarantine( + address + ) external returns (LazyOracle.QuarantineInfo) envfree; + function LazyOracleHarness.handleSanityChecks( + address, uint256, uint48, uint256, uint256, uint256, uint256 + ) external returns (uint256, int256) envfree; + + // `MerkleProof` library + function MerkleProof.verify( + bytes32[] memory, bytes32, bytes32 + ) internal returns (bool) => ALWAYS(true); + + // `PredepositGuarantee` + // Strictly speaking this summary is not sound, but it is sufficient here + function _.proveUnknownValidator( + IPredepositGuarantee.ValidatorWitness, address + ) external => NONDET; + + // `StakingVault` + // Without the following summary, the call from `VaultHub`:Line 1071, + // `_predepositGuarantee().proveUnknownValidator(_witness, IStakingVault(_vault))`, + // becomes unresolved ("callee contract unresolved"). + function _.withdraw(address, uint256) external => DISPATCHER(true); + + function _.beaconChainDepositsPaused() external => DISPATCHER(true); + function _.resumeBeaconChainDeposits() external => DISPATCHER(true); + function _.pauseBeaconChainDeposits() external => DISPATCHER(true); + function _.transferOwnership(address) external => DISPATCHER(true); + function _.pendingOwner() external => DISPATCHER(true); + function _.depositor() external => DISPATCHER(true); + function _.owner() external => DISPATCHER(true); + function _.nodeOperator() external => DISPATCHER(true); + function _.acceptOwnership() external => DISPATCHER(true); + function _.fund() external => DISPATCHER(true); + function _.requestValidatorExit(bytes) external => DISPATCHER(true); + function _.triggerValidatorWithdrawals(bytes, uint64[], address) external => DISPATCHER(true); +} + + +/// @dev Same as `LazyOracle.TOTAL_BASIS_POINTS` +definition TOTAL_BASIS_POINTS() returns uint256 = 10000; + +/// @dev Adapted from `LazyOracle._processTotalValue` +definition getQuarantineThreshold(mathint onchainTotalValueOnRefSlot) returns mathint = ( + onchainTotalValueOnRefSlot * + (TOTAL_BASIS_POINTS() + _LazyOracle.lo_storage.maxRewardRatioBP) / + TOTAL_BASIS_POINTS() +); + +/// @title Basic integrity for quarantines +rule quarantineIntegrity( + address vault, + uint256 totalValue, + uint256 cumulativeLidoFees, + uint256 liabilityShares, + uint256 maxLiabilityShares, + uint256 slashingReserve +) { + reasonableDeltaValues(vault); // Prevent overflows and underflows + + uint64 dataTimestamp = _LazyOracle.lo_storage.vaultsDataTimestamp; + uint48 refSlot = _LazyOracle.lo_storage.vaultsDataRefSlot; + LazyOracle.QuarantineInfo infoPre = _LazyOracle.vaultQuarantine(vault); + + uint104 totalPre = _VaultHub.getVaultReportTotal(vault); + int104 recDeltaPreRef = _VaultHub.getVaultRecordInOutDelta(vault, refSlot); + int104 repDeltaPre = _VaultHub.getVaultReportDelta(vault); + + // Adapted from `LazyOracle._processTotalValue` + mathint onchainTotalValueOnRefSlot = totalPre + recDeltaPreRef - repDeltaPre; + mathint quarantineThreshold = getQuarantineThreshold(onchainTotalValueOnRefSlot); + + require( + infoPre.pendingTotalValueIncrease <= maxReasonableValue(), + "Prevent overflows and underflows" + ); + + env e; + bytes32[] proof; + _LazyOracle.updateVaultData( + e, + vault, + totalValue, + cumulativeLidoFees, + liabilityShares, + maxLiabilityShares, + slashingReserve, + proof + ); + + LazyOracle.QuarantineInfo infoPost = _LazyOracle.vaultQuarantine(vault); + uint104 totalPost = _VaultHub.getVaultReportTotal(vault); + + assert( + ( + infoPre.isActive && + infoPre.endTimestamp > dataTimestamp && + totalValue > quarantineThreshold && + _VaultHub.isVaultConnected(vault) + ) => ( + infoPost.isActive && + infoPre.pendingTotalValueIncrease == infoPost.pendingTotalValueIncrease + ), + "Quarantine is active until end time" + ); + assert( + (infoPre.isActive && infoPre.endTimestamp > dataTimestamp) => ( + totalPost <= onchainTotalValueOnRefSlot || + totalPost <= totalValue + ), + "Funds are not released while quarantine is active" + ); + assert( + (infoPre.isActive && infoPre.endTimestamp <= dataTimestamp) => ( + totalPost >= onchainTotalValueOnRefSlot + infoPre.pendingTotalValueIncrease || + totalPost == totalValue || + !_VaultHub.isVaultConnected(vault) + ), + "Funds are released after quarantine ends" + ); + assert(totalPost <= totalValue); +} + + +/// @title Quarantine state consistency +invariant quarantineStateConsistency(address vault) + // Active quarantine must have non-zero timestamp and valid end time + (_LazyOracle.vaultQuarantine(vault).isActive => + ( + _LazyOracle.vaultQuarantine(vault).startTimestamp > 0 && + _LazyOracle.vaultQuarantine(vault).pendingTotalValueIncrease > 0 && + ( + _LazyOracle.lo_storage.quarantinePeriod == 0 || + _LazyOracle.vaultQuarantine(vault).endTimestamp > + _LazyOracle.vaultQuarantine(vault).startTimestamp + ) + ) + ) && + // Inactive quarantine must be completely zeroed + (!_LazyOracle.vaultQuarantine(vault).isActive => + ( + _LazyOracle.vaultQuarantine(vault).startTimestamp == 0 && + _LazyOracle.vaultQuarantine(vault).pendingTotalValueIncrease == 0 + ) + ) + filtered { f -> f.contract == _LazyOracle } + + +/// @title Revert conditions for `_handleSanityChecks` +rule handleSanityChecksRevertConditions( + address vault, + uint256 totalValue, + uint48 _reportRefSlot, + uint256 _reportTimestamp, + uint256 _cumulativeLidoFees, + uint256 _liabilityShares, + uint256 _maxLiabilityShares +) { + reasonableDeltaValues(vault); // Prevent overflows and underflows + + require( + _reportTimestamp == _LazyOracle.lo_storage.vaultsDataTimestamp, + "Assume report is updated" + ); + + LazyOracle.QuarantineInfo infoPre = _LazyOracle.vaultQuarantine(vault); + require( + _reportTimestamp <= max_uint48 && // Prevent overflow, this is reasonable time + _reportTimestamp >= infoPre.startTimestamp && + _reportTimestamp > _VaultHub.vh_storage.records[vault].report.timestamp, + "Time integrity" + ); + require( + infoPre.pendingTotalValueIncrease <= maxReasonableValue(), + "Prevent overflows and underflows" + ); + requireInvariant quarantineStateConsistency(vault); + + uint104 totalPre = _VaultHub.getVaultReportTotal(vault); + int104 recDeltaPreRef = _VaultHub.getVaultRecordInOutDelta(vault, _reportRefSlot); + int104 repDeltaPre = _VaultHub.getVaultReportDelta(vault); + // Adapted from `LazyOracle._processTotalValue` + mathint onchainTotalValueOnRefSlot = totalPre + recDeltaPreRef - repDeltaPre; + mathint quarantineThreshold = getQuarantineThreshold(onchainTotalValueOnRefSlot); + int104 currDelta = _VaultHub.getVaultRecordDeltaValue(vault); + + _LazyOracle.handleSanityChecks@withrevert( + vault, + totalValue, + _reportRefSlot, + _reportTimestamp, + _cumulativeLidoFees, + _liabilityShares, + _maxLiabilityShares + ); + bool reverted = lastReverted; + + mathint deltas = currDelta - recDeltaPreRef; + mathint prevCumulativeLidoFees = _VaultHub.vh_storage.records[vault].cumulativeLidoFees; + mathint prevMaxLiabilityShares = _VaultHub.vh_storage.records[vault].maxLiabilityShares; + // See `LazyOracle._handleSanityChecks` Line 409 + mathint maxLidoFees = ( + (_reportTimestamp - _VaultHub.vh_storage.records[vault].report.timestamp) + * _LazyOracle.lo_storage.maxLidoFeeRatePerSecond + ); + assert( + reverted <=> ( + // The following two lines handle the underflow condition (3) in `_handleSanityChecks` + totalValue + deltas < 0 || + onchainTotalValueOnRefSlot + infoPre.pendingTotalValueIncrease + deltas < 0 || + // Condition in `LazyOracle._processTotalValue` + totalValue > max_uint96 || + // Overflows in `LazyOracle._processTotalValue` + ( + onchainTotalValueOnRefSlot > max_uint256 || + onchainTotalValueOnRefSlot < 0 || + onchainTotalValueOnRefSlot * ( + TOTAL_BASIS_POINTS() + _LazyOracle.lo_storage.maxRewardRatioBP + ) > max_uint256 + ) || + // Condition (4) in `LazyOracle._handleSanityChecks` + ( + prevCumulativeLidoFees > _cumulativeLidoFees || + _cumulativeLidoFees - prevCumulativeLidoFees > maxLidoFees + ) || + // Condition (5) in `LazyOracle._handleSanityChecks` + ( + _maxLiabilityShares < _liabilityShares || + _maxLiabilityShares > prevMaxLiabilityShares + ) + ) + ); +} + + +/// @title Ensure that once a quarantine expires it cannot be reused +/// @notice Fails, see `https://github.com/lidofinance/core/issues/1304` +rule quarantineExpiry( + address vault, + uint256 totalValue, + uint256 cumulativeLidoFees, + uint256 liabilityShares, + uint256 maxLiabilityShares, + uint256 slashingReserve +) { + reasonableDeltaValues(vault); // Prevent overflows and underflows + + uint64 dataTimestamp = _LazyOracle.lo_storage.vaultsDataTimestamp; + LazyOracle.QuarantineInfo infoPre = _LazyOracle.vaultQuarantine(vault); + + env e; + bytes32[] proof; + _LazyOracle.updateVaultData( + e, + vault, + totalValue, + cumulativeLidoFees, + liabilityShares, + maxLiabilityShares, + slashingReserve, + proof + ); + + LazyOracle.QuarantineInfo infoPost = _LazyOracle.vaultQuarantine(vault); + + assert( + // Active quarantine expired + (infoPre.isActive && infoPre.endTimestamp <= dataTimestamp) => ( + !infoPost.isActive || // Current quarantine expired + infoPost.pendingTotalValueIncrease == 0 || // Current quarantine expired + infoPost.startTimestamp == dataTimestamp // New quarantine + ), + "Current quarantine must expire after end time" + ); +} diff --git a/certora/specs/vaults/lido-mock.spec b/certora/specs/vaults/lido-mock.spec new file mode 100644 index 0000000000..7f2d7f1351 --- /dev/null +++ b/certora/specs/vaults/lido-mock.spec @@ -0,0 +1,131 @@ +/* A mock for `Lido` based on `ILidoMock` */ + +methods { + + // `ILidoMock` + function ILidoMock.mintExternalShares( + address _recipient, uint256 _amountOfShares + ) external => CVLmintExternalShares(_amountOfShares); + function ILidoMock.burnExternalShares( + uint256 _amountOfShares + ) external => CVLburnExternalShares(_amountOfShares); + function ILidoMock.getSharesByPooledEth( + uint256 _ethAmount + ) external returns (uint256) => CVLgetSharesByPooledEth(_ethAmount); + function ILidoMock.getPooledEthByShares( + uint256 _sharesAmount + ) external returns (uint256) => CVLgetPooledEthByShares(_sharesAmount); + function ILidoMock.getPooledEthBySharesRoundUp( + uint256 _sharesAmount + ) external returns (uint256) => CVLgetPooledEthBySharesRoundUp(_sharesAmount); + function ILidoMock.rebalanceExternalEtherToInternal( + ) external with (env e) => CVLrebalanceExternalEtherToInternal(e.msg.value); + function ILidoMock.getTotalShares() external returns (uint256) => _totalShares; + + // NOTE: This summary may not be sound - it returns NONDET for simplification + function ILidoMock.transferSharesFrom( + address, address, uint256 + ) external returns (uint256) => NONDET; +} +// -- Summary ghosts and functions --------------------------------------------- + +ghost uint256 _totalShares { + // NOTE: Requirement to prevent overflow - total shares bounded by uint128 + axiom _totalShares <= max_uint128; +} + + +ghost uint256 _externalShares { + // NOTE: External shares must always be less than total shares + axiom _externalShares < _totalShares; +} + + +ghost uint256 _internalEth { + // NOTE: Internal ETH must be positive and bounded by uint128 + // The positivity requirement is an assumption to avoid division by zero + axiom _internalEth > 0 && _internalEth <= max_uint128; +} + + +definition _internalShares() returns uint256 = ( + assert_uint256(_totalShares - _externalShares) +); + + +/// @dev Summarizes `Lido.getSharesByPooledEth` +/// @notice While the original function will revert if `_ethAmount` exceeds `UINT128_MAX`, +/// this summary will not. +function CVLgetSharesByPooledEth(uint256 _ethAmount) returns uint256 { + require( + _ethAmount <= max_uint128, + "Lido.getSharesByPooledEth reverts if _ethAmount is bigger" + ); + uint256 numeratorInEther = _internalEth; + uint256 denominatorInShares = _internalShares(); + return require_uint256((_ethAmount * denominatorInShares) / numeratorInEther); +} + + +/// @dev Summarizes `Lido.getPooledEthBySharesRoundUp` +/// @notice While the original function will revert if `_sharesAmount` exceeds `UINT128_MAX`, +/// this summary will not. +function CVLgetPooledEthBySharesRoundUp(uint256 _sharesAmount) returns uint256 { + require( + _sharesAmount <= max_uint128, + "Lido.getPooledEthBySharesRoundUp reverts if _sharesAmount is bigger" + ); + uint256 numeratorInEther = _internalEth; + uint256 denominatorInShares = _internalShares(); + + return assert_uint256( + // Add `denominatorInShares - 1` to round up + (_sharesAmount * numeratorInEther + denominatorInShares - 1) + / denominatorInShares + ); +} + + +/// @dev Summarizes `Lido.getPooledEthByShares` +/// @notice While the original function will revert if `_sharesAmount` exceeds `UINT128_MAX`, +/// this summary will not. +function CVLgetPooledEthByShares(uint256 _sharesAmount) returns uint256 { + require( + _sharesAmount <= max_uint128, + "Lido.getPooledEthBySharesRoundUp reverts if _sharesAmount is bigger" + ); + uint256 numeratorInEther = _internalEth; + uint256 denominatorInShares = _internalShares(); + + return assert_uint256( + (_sharesAmount * numeratorInEther) / denominatorInShares + ); +} + + +/// @dev Summarizes `Lido.mintExternalShares` +/// @notice While the original function will revert if either `_recipient` or +/// `_amountOfShares` is zero, or `_amountOfShares` is too high, this summary will not. +function CVLmintExternalShares(uint256 _amountOfShares) { + _externalShares = require_uint256(_externalShares + _amountOfShares); + _totalShares = require_uint256(_totalShares + _amountOfShares); +} + + +/// @dev Summarizes `Lido.burnExternalShares` +/// @notice While the original function will revert if `_amountOfShares` is zero +/// or too large this summary will not. +function CVLburnExternalShares(uint256 _amountOfShares) { + _externalShares = require_uint256(_externalShares - _amountOfShares); + _totalShares = require_uint256(_totalShares - _amountOfShares); +} + + +/// @dev Summarizes `Lido.rebalanceExternalEtherToInternal` +/// @notice While the original function will revert if `msg_value` is zero or too large +/// this summary will not. +function CVLrebalanceExternalEtherToInternal(uint256 msg_value) { + uint256 amountOfShares = CVLgetSharesByPooledEth(msg_value); + _externalShares = require_uint256(_externalShares - amountOfShares); + _internalEth = require_uint256(_internalEth + msg_value); +} diff --git a/certora/specs/vaults/predeposit.spec b/certora/specs/vaults/predeposit.spec new file mode 100644 index 0000000000..97f8a6140e --- /dev/null +++ b/certora/specs/vaults/predeposit.spec @@ -0,0 +1,129 @@ +/* Spec for the `PredepositGuarantee` contract */ + +using PredepositGuarantee as _PredepositGuarantee; + +methods { + // `PredepositGuarantee` + function PredepositGuarantee.validatorStatus( + bytes + ) external returns (IPredepositGuarantee.ValidatorStatus) envfree; + // The following pure function causes sanity problems, therefore summarized + function PredepositGuarantee._depositDataRootWithZeroSig( + bytes calldata, + uint256, + bytes32 + ) internal returns (bytes32) => NONDET; + + + // `StakingVault` + // Without the following summary, the call from `VaultHub`:Line 1071, + // `_predepositGuarantee().proveUnknownValidator(_witness, IStakingVault(_vault))`, + // becomes unresolved ("callee contract unresolved"). + function _.withdraw(address, uint256) external => DISPATCHER(true); + + function _.beaconChainDepositsPaused() external => DISPATCHER(true); + function _.resumeBeaconChainDeposits() external => DISPATCHER(true); + function _.pauseBeaconChainDeposits() external => DISPATCHER(true); + function _.transferOwnership(address) external => DISPATCHER(true); + function _.pendingOwner() external => DISPATCHER(true); + function _.depositor() external => DISPATCHER(true); + function _.owner() external => DISPATCHER(true); + function _.nodeOperator() external => DISPATCHER(true); + function _.acceptOwnership() external => DISPATCHER(true); + function _.fund() external => DISPATCHER(true); + function _.requestValidatorExit(bytes) external => DISPATCHER(true); + function _.triggerValidatorWithdrawals(bytes, uint64[], address) external => DISPATCHER(true); + function _.stage(uint256) external => DISPATCHER(true); + + // The function `depositToBeaconChain` deposits to various contracts. To simplify things + // we summarized it as `NONDET`, although this is not strictly speaking sound. + function _.depositToBeaconChain(IStakingVault.Deposit _deposit) external => NONDET; + + // `BLS` Library + // Summarizing the `BLS` library since the Prover cannot easily handle such + // calculations and it contains many unsafe memory operations that hurt static + // analysis. Using NONDET as it's the most practical approach for verification. + function BLS12_381.verifyDepositMessage( + bytes calldata, + bytes calldata, + uint256, + BLS12_381.DepositY calldata, + bytes32, + bytes32 + ) internal => NONDET; + function BLS12_381.sha256Pair(bytes32, bytes32) internal returns (bytes32) => NONDET; + function BLS12_381.pubkeyRoot(bytes calldata) internal returns (bytes32) => NONDET; + + // `SSZ` Library + // NOTE: Summarized as NONDET due to complexity of SSZ operations + function SSZ.hashTreeRoot(SSZ.BeaconBlockHeader memory) internal returns (bytes32) => NONDET; + function SSZ.hashTreeRoot(SSZ.Validator memory) internal returns (bytes32) => NONDET; + function SSZ.verifyProof(bytes32[] calldata, bytes32, bytes32, SSZ.GIndex) internal => NONDET; + + // `CLProofVerifier` + // NOTE: Summarized as NONDET due to complexity of proof verification + function CLProofVerifier._validatePubKeyWCProof( + IPredepositGuarantee.ValidatorWitness calldata, + bytes32 + ) internal => NONDET; +} + + +/// @title Valid transitions for validator status +rule validatorStatusTransitions(method f, bytes validatorKey) filtered { + f -> !f.isView +} { + IPredepositGuarantee.ValidatorStatus statusPre = _PredepositGuarantee.validatorStatus( + validatorKey + ); + + env e; + calldataarg args; + f(e, args); + + IPredepositGuarantee.ValidatorStatus statusPost = _PredepositGuarantee.validatorStatus( + validatorKey + ); + + assert( + statusPre.stage == IPredepositGuarantee.ValidatorStage.NONE => ( + statusPost.stage == IPredepositGuarantee.ValidatorStage.NONE || + statusPost.stage == IPredepositGuarantee.ValidatorStage.PREDEPOSITED || + ( + statusPost.stage == IPredepositGuarantee.ValidatorStage.ACTIVATED && + f.selector == sig:PredepositGuarantee.proveUnknownValidator( + IPredepositGuarantee.ValidatorWitness, address + ).selector + ) + ), + "Transitions from NONE stage" + ); + assert( + statusPre.stage == IPredepositGuarantee.ValidatorStage.PREDEPOSITED => ( + statusPost.stage == IPredepositGuarantee.ValidatorStage.PREDEPOSITED || + statusPost.stage == IPredepositGuarantee.ValidatorStage.PROVEN || + statusPost.stage == IPredepositGuarantee.ValidatorStage.ACTIVATED || + statusPost.stage == IPredepositGuarantee.ValidatorStage.COMPENSATED + ), + "Transitions from PREDEPOSITED stage" + ); + assert( + statusPre.stage == IPredepositGuarantee.ValidatorStage.PROVEN => ( + statusPost.stage == IPredepositGuarantee.ValidatorStage.PROVEN || + statusPost.stage == IPredepositGuarantee.ValidatorStage.ACTIVATED + ), + "Transitions from PROVEN stage" + ); + assert( + statusPre.stage == IPredepositGuarantee.ValidatorStage.ACTIVATED => ( + statusPost.stage == IPredepositGuarantee.ValidatorStage.ACTIVATED + ), + "ACTIVATED is a terminal status" + ); + assert( + statusPre.stage == IPredepositGuarantee.ValidatorStage.COMPENSATED => ( + statusPost.stage == IPredepositGuarantee.ValidatorStage.COMPENSATED + ), + "COMPENSATED is a terminal status" + ); +} diff --git a/certora/specs/vaults/shortfall.spec b/certora/specs/vaults/shortfall.spec new file mode 100644 index 0000000000..6690fa12a5 --- /dev/null +++ b/certora/specs/vaults/shortfall.spec @@ -0,0 +1,174 @@ +import "./VaultHub.spec"; + + +methods { + function OperatorGrid.onBurnedShares(address, uint256) external => NONDET; + function OperatorGrid.onMintedShares(address, uint256, bool) external => NONDET; + + // Ignore side-effects of rebalancing on internal-ETH to internal-shares ratio. + // Without this, rebalancing increases _internalEth which increases share price, + // causing remaining liability shares to convert to more ETH, potentially making + // the vault unhealthy again. This is the approach used in approximated-VaultHub.spec. + function ILidoMock.rebalanceExternalEtherToInternal(uint256) external => NONDET; + + // Simplify external calls that don't affect the core property + function _.pauseBeaconChainDeposits() external => NONDET; + function _.resumeBeaconChainDeposits() external => NONDET; +} + + + + +/** +function reasonableDeltaValues(address vault) { + int112 recordDelta = _VaultHub.getVaultRecordDeltaValue(vault); + require (recordDelta <= maxReasonableValue()) && (recordDelta >= -maxReasonableValue()); + + int112 recordDeltaRef = _VaultHub.getVaultRecordDeltaRef(vault); + require (recordDeltaRef <= maxReasonableValue()) && (recordDeltaRef >= -maxReasonableValue()); + + int112 reportDelta = _VaultHub.getVaultReportDelta(vault); + require (reportDelta <= maxReasonableValue()) && (reportDelta >= -maxReasonableValue()); + + uint112 reportTot = _VaultHub.getVaultReportTotal(vault); + require (reportTot <= maxReasonableValue()); + + mathint totValue = reportTot + recordDelta - reportDelta; + require totValue >= 0; + + mathint totRef = reportTot + recordDeltaRef - reportDelta; + require totRef >= 0; +}*/ +/* +rule shortfallValueIsSufficient(address vault) { + // Assume reasonable share price + env e; + uint256 internal_eth = _internalEth; + uint256 internal_shares = _internalShares(); + require(internal_shares == internal_eth, "Assume 1 share = 1 ETH"); + + require(_VaultHub.isVaultConnected(vault), "Assume connected vault"); + + reasonableDeltaValues(vault); + + // Lido's pool must be much larger than any individual vault (realistic assumption) + // Without this, counter-examples have Lido with 1 wei while vault has huge values + require( + internal_shares >= 100 * _VaultHub.liabilityShares(vault) && + internal_eth >= 100 * _VaultHub.totalValue(vault), + "Assume Lido holds many more shares and ETH than the vault" + ); + + // Require reasonable reserve ratio bounds (typical values are 10-50%, i.e., 1000-5000 BP) + // Very high reserve ratios (>90%) make the shortfall formula unstable due to integer rounding + uint16 reserveRatio = _VaultHub.reserveRatioBP(vault); + uint16 threshold = _VaultHub.forcedRebalanceThresholdBP(vault); + require(reserveRatio >= 100, "Reserve ratio must be at least 1%"); + require(reserveRatio <= 9000, "Reserve ratio must be at most 90% for stable math"); + // Ensure sufficient margin between reserve ratio and threshold for rounding tolerance + require(reserveRatio >= threshold + 10, "Need margin between reserveRatio and threshold"); + + requireInvariant vaultReserveRatioGeThreshold(vault); + requireInvariant vaultLockedCoversLiabilityAndReserve(vault); + + // Ensure reasonable liability for meaningful test (avoid edge cases with tiny values) + require(_VaultHub.liabilityShares(vault) >= 1000, "Liability should be non-trivial"); + require(_VaultHub.totalValue(vault) >= 1000, "Total value should be non-trivial"); + + // The vault must have enough total value to cover its locked amount + // Without this, the vault is underwater and cannot be fixed by rebalancing + require(_VaultHub.totalValue(vault) >= _VaultHub.locked(vault), "Vault must not be underwater"); + + // Simplify: assume maxLiabilityShares == liabilityShares (no extra minting in this period) + // This avoids edge cases where the shortfall gets capped at liabilityShares + requireInvariant maxLiabilitySharesGeqLiabilityShares(e, vault); + require( + _VaultHub.maxLiabilityShares(e, vault) == _VaultHub.liabilityShares(vault), + "Assume no extra minting beyond current liability" + ); + + require(!_VaultHub.isVaultHealthy(vault), "Start from an unhealthy vault"); + + // Compute shortfall shares + uint256 shortfall = _VaultHub.healthShortfallShares(vault); + + require(shortfall < max_uint256, "bad debt: cannot be fixed by rebalance"); + + // Ensure the shortfall doesn't get capped (which would indicate an edge case) + require(shortfall <= _VaultHub.liabilityShares(vault), "Shortfall should not exceed liability"); + + // rebalance + _VaultHub.rebalance(e, vault, shortfall); + + assert _VaultHub.isVaultHealthy(vault); +} +*/ + + +/*/// @title A vault is unhealthy if and only if its shortfall is non-zero +/// @notice This fails, see comment in `https://github.com/lidofinance/core/issues/1305`. +/// Also see `https://prover.certora.com/output/98279/ee7f7d49f5d74d07b64edb33e220cc70` +rule unhealthyVaultIffShortfallNonzero(address vault) { + reasonableDeltaValues(vault); + uint256 shortfall = _VaultHub.healthShortfallShares(vault); + bool isHealthy = _VaultHub.isVaultHealthy(vault); + assert shortfall == 0 <=> isHealthy; +} + +/// @title Non-zero shortfall implies vault is unhealthy +rule nonZeroShortfallIsUnhealthy(address vault) { + reasonableDeltaValues(vault); + uint256 shortfall = _VaultHub.healthShortfallShares(vault); + bool isHealthy = _VaultHub.isVaultHealthy(vault); + assert shortfall > 0 => !isHealthy; +} + + +// /// @dev Produces a simple example the shortfall value is too small +// rule insufficientShortfallValueExample(address vault) { +// reasonableDeltaValues(vault); +// requireInvariant vaultReserveRatioGeThreshold(vault); +// requireInvariant vaultLockedCoversLiabilityAndReserve(vault); + +// uint256 shortfall = _VaultHub.healthShortfallShares(vault); +// require( +// shortfall >= 10 && shortfall < max_uint256, +// "Assume vault is unhealthy but can be made healthy by rebalancing" +// ); + +// uint256 totalValue = _VaultHub.totalValue(vault); +// uint256 shares = _VaultHub.liabilityShares(vault); + +// require(totalValue == 1000 && shares >= 100, "Simpler example"); +// require( +// _VaultHub.forcedRebalanceThresholdBP(vault) <= 3000 && +// _VaultHub.forcedRebalanceThresholdBP(vault) >= 1000 && +// _VaultHub.reserveRatioBP(vault) <= 3000 && +// _VaultHub.reserveRatioBP(vault) >= 1000, +// "Assume small or simple values for simpler example" +// ); +// require( +// _internalShares() >= 100 * _VaultHub.liabilityShares(vault) && +// _internalEth >= 100 * _VaultHub.totalValue(vault), +// "Assume Lido holds many more shares and ETH than the vault" +// ); + + +// uint256 internal_eth = _internalEth; +// uint256 internal_shares = _internalShares(); + +// // 1 <= (internal_eth/internal_shares) <= 2 +// // <--> internal_shares <= internal_eth <= 2*internal_shares + +// require(internal_shares <= internal_eth && internal_eth <= require_uint256(2 * internal_shares), "Assume share price between 1 and 2 eth"); + + +// require(_VaultHub.isVaultConnected(vault), "Assume connected vault"); + +// env e; +// _VaultHub.rebalance(e, vault, shortfall); +// assert _VaultHub.isVaultHealthy(vault); +// } + + +*/ \ No newline at end of file diff --git a/certora/specs/vaults/vaults-array.spec b/certora/specs/vaults/vaults-array.spec new file mode 100644 index 0000000000..d1f04522d5 --- /dev/null +++ b/certora/specs/vaults/vaults-array.spec @@ -0,0 +1,242 @@ +/* Proves the `vaults` array in `VaultHub` is a set +*/ + +using VaultHubHarness as _VaultHub; + +methods { + // `VaultHubHarness` + function VaultHubHarness.totalValue(address) external returns (uint256) envfree; + function VaultHubHarness.locked(address) external returns (uint256) envfree; + function VaultHubHarness.isPendingDisconnect(address) external returns (bool) envfree; + function VaultHubHarness.isVaultConnected(address) external returns (bool) envfree; + function VaultHubHarness.isVaultHealthy(address) external returns (bool) envfree; + function VaultHubHarness.getVaultRecordDeltaValue(address) external returns (int104) envfree; + function VaultHubHarness.getVaultRecordInOutDelta( + address, uint48 + ) external returns (int104) envfree; + function VaultHubHarness.getVaultRecordBothDeltas( + address + ) external returns (int104, int104) envfree; + function VaultHubHarness.getVaultReportDelta(address) external returns (int104) envfree; + function VaultHubHarness.getVaultReportTotal(address) external returns (uint104) envfree; + function VaultHubHarness.obligationsShares(address) external returns (uint256) envfree; + function VaultHubHarness.unsettledLidoFees(address) external returns (uint256) envfree; + function VaultHubHarness.totalValue(address) external returns (uint256) envfree; + function VaultHubHarness.healthShortfallShares(address) external returns (uint256) envfree; + function VaultHubHarness.totalMintingCapacityShares(address _vault, int256 _deltaValue) external returns (uint256) envfree; + function VaultHubHarness.liabilityShares(address) external returns (uint256) envfree; + function VaultHubHarness.redemptionShares(address) external returns (uint128) envfree; + function VaultHubHarness.badDebtToInternalize() external returns (uint256) envfree; + function VaultHubHarness.vaultsArrayLength() external returns (uint256) envfree; + function VaultHubHarness.vaultArrayAtIndex(uint256) external returns (address) envfree; + function VaultHubHarness.getInitializedVersion() external returns (uint64) envfree; + function VaultHubHarness.vaultConnection( + address + )external returns (VaultHub.VaultConnection) envfree; + function VaultHubHarness.reserveRatioBP(address) external returns (uint16) envfree; + function VaultHubHarness.forcedRebalanceThresholdBP(address) external returns (uint16) envfree; +} + +// -- Utility functions -------------------------------------------------------- + +/// @dev Requirements that are needed in invariants for `_VaultHub.applyVaultReport`. +/// These are needed to prevent the case where a vault with index 0 is deleted +/// and therefore another becomes disconnected. +/// @notice Assumes `initialize` is called immediately after constructor (verified with Lido) +function applyVaultReportRquirements(address _other) { + require( + isInitialized(), + "Assumes `initialize` is called immediately after constructor" + ); + requireInvariant vaultsArrayIsNeverEmpty(); + + // In case `_other` is deleted + requireInvariant disconnectedVaultIsNotPending(_other); // So its index is not 0 + requireInvariant vaultToIndexIsCorrect(_other); + + // NOTE: This limits the number of vaults to `max_uint96` + uint96 lastIndex = require_uint96(vaultsLength() - 1); + address lastVault = vaults(lastIndex); + requireInvariant vaultToIndexIsCorrect(lastVault); + requireInvariant indexToVaultIsCorrect(lastIndex); +} + + +/// @dev Requirements that are needed in invariants for `_VaultHub.connectVault`. +/// These are needed to prevent a newly connected vault from being in index 0 and +/// therefore disconnected. +/// @notice Assumes `initialize` is called immediately after constructor (verified with Lido) +function connectVaultRequirements() { + require( + isInitialized(), + "Assumes `initialize` is called immediately after constructor" + ); + requireInvariant vaultsArrayIsNeverEmpty(); +} + +/// @dev Returns whether the `VaultHub` has been initialized +definition isInitialized() returns bool = _VaultHub.getInitializedVersion() == 1; + +/// @dev The length of the `vaults` array +definition vaultsLength() returns uint256 = _VaultHub.vh_storage.vaults.length; + +/// @dev The vault in the given index of the array +definition vaults(uint96 index) returns address = ( + _VaultHub.vh_storage.vaults[assert_uint256(index)] +); + +/// @dev The index of the given vault (according to the inverse mapping) +definition vaultIndex(address vault) returns uint96 = ( + _VaultHub.vh_storage.connections[vault].vaultIndex +); + + +definition maxReasonableValue() returns mathint = 2^100; + + +/// @dev Sets limits on the vault's possible values +/// @notice Missing conditions on previous slots using `DoubleRefSlotCache.getValueForRefSlot` +function reasonableDeltaValues(address vault) { + // Just to be on the safe side we require for current delta as well as both deltas + int104 recordDelta = _VaultHub.getVaultRecordDeltaValue(vault); + require (recordDelta <= maxReasonableValue()) && (recordDelta >= -maxReasonableValue()); + + int104 delta0; + int104 delta1; + (delta0, delta1) = _VaultHub.getVaultRecordBothDeltas(vault); + require ( + delta0 <= maxReasonableValue() && delta0 >= -maxReasonableValue() && + delta1 <= maxReasonableValue() && delta1 >= -maxReasonableValue() + ); + + int104 reportDelta = _VaultHub.getVaultReportDelta(vault); + require (reportDelta <= maxReasonableValue()) && (reportDelta >= -maxReasonableValue()); + + uint104 reportTot = _VaultHub.getVaultReportTotal(vault); + require (reportTot <= maxReasonableValue()); + + mathint totValue = reportTot + recordDelta - reportDelta; + require totValue >= 0; +} + +// -- Invariants --------------------------------------------------------------- + +/// @title A vault that is pending disconnect is connected +invariant disconnectedVaultIsNotPending(address vault) + _VaultHub.isPendingDisconnect(vault) => _VaultHub.isVaultConnected(vault) + filtered { + f -> f.contract == _VaultHub // `VaultHub` is sufficient for this invariant + } + { + preserved _VaultHub.applyVaultReport( + address _other, + uint256 _reportTimestamp, + uint256 _reportTotalValue, + int256 _reportInOutDelta, + uint256 _reportCumulativeLidoFees, + uint256 _reportLiabilityShares, + uint256 _reportMaxLiabilityShares, + uint256 _reportSlashingReserve + ) with (env e) { + requireInvariant disconnectedVaultIsNotPending(_other); + require( + isInitialized(), + "Assumes `initialize` is called immediately after constructor" + ); + requireInvariant vaultsArrayIsNeverEmpty(); + } + } + + +/// @title The `vaults` array in `VaultHub` has address 0 at index 0 after initialization +invariant vaultsArrayIsNeverEmpty() + (vaultIndex(0) == 0) && (isInitialized() => (vaultsLength() > 0 && vaults(0) == 0)) + filtered { + f -> f.contract == _VaultHub // `VaultHub` is sufficient for this invariant + } + { + preserved { + require( + isInitialized(), + "Assumes `initialize` is called immediately after constructor" + ); + } + preserved initialize(address _admin) with (env e) { + require( + _VaultHub.vaultsArrayLength() == 0, + "Assumes `initialize` is called immediately after constructor" + ); + } + preserved _VaultHub.applyVaultReport( + address _other, + uint256 _reportTimestamp, + uint256 _reportTotalValue, + int256 _reportInOutDelta, + uint256 _reportCumulativeLidoFees, + uint256 _reportLiabilityShares, + uint256 _reportMaxLiabilityShares, + uint256 _reportSlashingReserve + ) with (env e) { + applyVaultReportRquirements(_other); + } + } + + +invariant indexToVaultIsCorrect(uint96 index) + index < vaultsLength() => (vaultIndex(vaults(index)) == index) + filtered { + f -> ( + f.contract == _VaultHub && // `VaultHub` is sufficient for this invariant + // NOTE: Filtering out `initialize` as it's a special case handled separately + f.selector != sig:VaultHubHarness.initialize(address).selector + ) + } + { + preserved _VaultHub.connectVault(address _other) with (env e) { + connectVaultRequirements(); + } + preserved _VaultHub.applyVaultReport( + address _other, + uint256 _reportTimestamp, + uint256 _reportTotalValue, + int256 _reportInOutDelta, + uint256 _reportCumulativeLidoFees, + uint256 _reportLiabilityShares, + uint256 _reportMaxLiabilityShares, + uint256 _reportSlashingReserve + ) with (env e) { + applyVaultReportRquirements(_other); + } + } + + +invariant vaultToIndexIsCorrect(address vault) + ( + (vaultIndex(vault) <= vaultsLength()) && + (vaultsLength() > 0 => vaultIndex(vault) < vaultsLength()) && + (vaultIndex(vault) > 0 => vaults(vaultIndex(vault)) == vault) + ) + filtered { + f -> ( + f.contract == _VaultHub && // `VaultHub` is sufficient for this invariant + // NOTE: Filtering out `initialize` as it's a special case handled separately + f.selector != sig:VaultHubHarness.initialize(address).selector + ) + } + { + preserved _VaultHub.connectVault(address _other) with (env e) { + connectVaultRequirements(); + } + preserved _VaultHub.applyVaultReport( + address _other, + uint256 _reportTimestamp, + uint256 _reportTotalValue, + int256 _reportInOutDelta, + uint256 _reportCumulativeLidoFees, + uint256 _reportLiabilityShares, + uint256 _reportMaxLiabilityShares, + uint256 _reportSlashingReserve + ) with (env e) { + applyVaultReportRquirements(_other); + } + } diff --git a/test/0.8.25/vaults/vaulthub/VaultHubHealthInvariant.t.sol b/test/0.8.25/vaults/vaulthub/VaultHubHealthInvariant.t.sol new file mode 100644 index 0000000000..100b72ff52 --- /dev/null +++ b/test/0.8.25/vaults/vaulthub/VaultHubHealthInvariant.t.sol @@ -0,0 +1,922 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity 0.8.25; + +/* + * ╠══════════════════════════════════════════════════════════════════════════════════════════╣ + * ║ HOW TO RUN: ║ + * ║ 1. Run the full invariant test campaign: ║ + * ║ forge test --match-contract VaultHubHealthInvariantTest -vvv ║ + * ╚══════════════════════════════════════════════════════════════════════════════════════════╝ + */ + +import "forge-std/Test.sol"; +import "forge-std/console.sol"; + +import {VaultHub} from "contracts/0.8.25/vaults/VaultHub.sol"; +import {StakingVault} from "contracts/0.8.25/vaults/StakingVault.sol"; +import {OperatorGrid, TierParams} from "contracts/0.8.25/vaults/OperatorGrid.sol"; +import {ILidoLocator} from "contracts/common/interfaces/ILidoLocator.sol"; +import {ILido} from "contracts/common/interfaces/ILido.sol"; + +// OpenZeppelin contracts for proxy pattern +import {UpgradeableBeacon} from "@openzeppelin/contracts-v5.2/proxy/beacon/UpgradeableBeacon.sol"; +import {PinnedBeaconProxy} from "contracts/0.8.25/vaults/PinnedBeaconProxy.sol"; +import {OwnableUpgradeable} from "contracts/openzeppelin/5.2/upgradeable/access/OwnableUpgradeable.sol"; +import { + TransparentUpgradeableProxy +} from "@openzeppelin/contracts-v5.2/proxy/transparent/TransparentUpgradeableProxy.sol"; + +// Import test harnesses and mocks +import {LazyOracle__HarnessForVaultHub} from "./contracts/LazyOracle__HarnessForVaultHub.sol"; +import {VaultFactory} from "contracts/0.8.25/vaults/VaultFactory.sol"; +import {VaultFactoryWrapper} from "./contracts/VaultFactoryWrapper.sol"; +import {MinimalDashboard} from "./contracts/MinimalDashboard.sol"; +import { + MockStETH, + MockLidoLocator, + MockHashConsensus, + MockPredepositGuarantee, + MockDepositContract +} from "./VaultHubInvariant.t.sol"; + +contract VaultHubHealthInvariantTest is Test { + // Constants + uint256 constant TOTAL_BASIS_POINTS = 10000; + uint256 constant CONNECT_DEPOSIT = 1 ether; + uint256 constant INITIAL_LIDO_BALANCE = 10000 ether; + + // Test parameters + uint256 constant SHARE_LIMIT = 100 ether; + uint256 constant RESERVE_RATIO_BP = 2000; // 20% + uint256 constant FORCED_REBALANCE_THRESHOLD_BP = 1800; // 18% + uint256 constant INFRA_FEE_BP = 500; + uint256 constant LIQUIDITY_FEE_BP = 400; + uint256 constant RESERVATION_FEE_BP = 100; + uint256 constant MAX_RELATIVE_SHARE_LIMIT_BP = 1000; // 10% + + // Core contracts - REAL + VaultHub public vaultHub; + OperatorGrid public operatorGrid; + StakingVault public vault; + + // Core contracts - MOCKED (external dependencies) + MockStETH public steth; + MockLidoLocator public locator; + MockHashConsensus public consensus; + LazyOracle__HarnessForVaultHub public lazyOracle; + VaultFactoryWrapper public vaultFactory; + MockPredepositGuarantee public pdg; + MockDepositContract public depositContract; + + // Beacon and proxy infrastructure for StakingVault + UpgradeableBeacon public vaultBeacon; + + // Test addresses + address public vaultAddress; + address public vaultOwner; + address public nodeOperator; + + // Handler for invariant testing + HealthHandler public handler; + + // Track if vault was healthy before the last operation + bool public wasHealthyBefore; + + // Track if last operation was settleLidoFees (excluded from invariant per spec) + bool public lastOpWasSettleFees; + + // Ghost variables to track statistics across ALL invariant runs + uint256 public ghost_fundCallCount; + uint256 public ghost_mintCallCount; + uint256 public ghost_withdrawCallCount; + uint256 public ghost_rebalanceCallCount; + uint256 public ghost_forceRebalanceCallCount; + uint256 public ghost_burnSharesCallCount; + uint256 public ghost_transferAndBurnCallCount; + uint256 public ghost_updateConnectionCallCount; + uint256 public ghost_pauseResumeCallCount; + uint256 public ghost_setLiabilityTargetCallCount; + uint256 public ghost_triggerWithdrawalsCallCount; + uint256 public ghost_settleLidoFeesCallCount; + + function setUp() public { + vaultOwner = makeAddr("vaultOwner"); + nodeOperator = makeAddr("nodeOperator"); + + // Give test contract and vault owner enough ETH for testing + vm.deal(address(this), 1000 ether); + vm.deal(vaultOwner, 1000 ether); + + // Deploy mock dependencies + steth = new MockStETH(); + vm.deal(address(steth), INITIAL_LIDO_BALANCE); + + // Initialize with realistic total shares to simulate a mature protocol + steth.setInitialShares(INITIAL_LIDO_BALANCE); + + // Set a realistic share rate (1.15x - simulates accumulated staking rewards) + steth.setShareRateBP(11500); // 1.15x rate + + consensus = new MockHashConsensus(); + depositContract = new MockDepositContract(); + pdg = new MockPredepositGuarantee(); + + // Create a treasury address for fee settlements + address treasury = makeAddr("treasury"); + + // Deploy locator (temporarily without OperatorGrid and LazyOracle) + locator = new MockLidoLocator( + address(steth), + address(pdg), + address(0), // lazyOracle - will be set later + address(0), // operatorGrid - will be set later + treasury, + address(0) // accounting + ); + + // Deploy REAL LazyOracle via harness and proxy + LazyOracle__HarnessForVaultHub lazyOracleImpl = new LazyOracle__HarnessForVaultHub(address(locator)); + TransparentUpgradeableProxy lazyOracleProxy = new TransparentUpgradeableProxy( + address(lazyOracleImpl), + address(this), + "" + ); + lazyOracle = LazyOracle__HarnessForVaultHub(address(lazyOracleProxy)); + + // Initialize with sanity params + lazyOracle.initialize( + address(this), // admin + 7 days, // quarantine period + 1000, // maxRewardRatioBP (10%) + 1 ether // maxLidoFeeRatePerSecond + ); + + // Update locator with LazyOracle + locator.setLazyOracle(address(lazyOracle)); + + // Deploy REAL OperatorGrid via proxy + OperatorGrid operatorGridImpl = new OperatorGrid(ILidoLocator(address(locator))); + TransparentUpgradeableProxy operatorGridProxy = new TransparentUpgradeableProxy( + address(operatorGridImpl), + address(this), // admin + "" // no initialization data here, call initialize separately + ); + operatorGrid = OperatorGrid(address(operatorGridProxy)); + + // Initialize OperatorGrid with default tier params + TierParams memory defaultTierParams = TierParams({ + shareLimit: SHARE_LIMIT, + reserveRatioBP: RESERVE_RATIO_BP, + forcedRebalanceThresholdBP: FORCED_REBALANCE_THRESHOLD_BP, + infraFeeBP: INFRA_FEE_BP, + liquidityFeeBP: LIQUIDITY_FEE_BP, + reservationFeeBP: RESERVATION_FEE_BP + }); + operatorGrid.initialize(address(this), defaultTierParams); + operatorGrid.grantRole(operatorGrid.REGISTRY_ROLE(), address(this)); + + // Update locator with OperatorGrid + locator.setOperatorGrid(address(operatorGrid)); + + // Deploy VaultHub implementation + VaultHub vaultHubImpl = new VaultHub( + ILidoLocator(address(locator)), + ILido(address(steth)), + consensus, + MAX_RELATIVE_SHARE_LIMIT_BP + ); + + // Deploy proxy and initialize through it + TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( + address(vaultHubImpl), + address(this), // admin + "" // no initialization data here, call initialize separately + ); + vaultHub = VaultHub(payable(address(proxy))); + + // Initialize VaultHub + vaultHub.initialize(address(this)); + + // Grant pause/resume roles + vaultHub.grantRole(vaultHub.PAUSE_ROLE(), address(this)); + vaultHub.grantRole(vaultHub.RESUME_ROLE(), address(this)); + + // Proxy storage doesn't inherit the paused state from implementation constructor + // We need to pause first, then resume to get to the correct state + vaultHub.pauseFor(365 days); + vaultHub.resume(); + + // Update locator with VaultHub address + locator.setVaultHub(address(vaultHub)); + + // Deploy REAL StakingVault implementation and beacon FIRST (needed for factory) + StakingVault vaultImpl = new StakingVault(address(depositContract)); + vaultBeacon = new UpgradeableBeacon(address(vaultImpl), address(this)); + + // Deploy REAL VaultFactory, then wrap it to intercept deployedVaults() calls + MinimalDashboard dashboardImpl = new MinimalDashboard(); + VaultFactory realFactory = new VaultFactory( + address(locator), // LIDO_LOCATOR + address(vaultBeacon), // BEACON + address(dashboardImpl), // DASHBOARD_IMPL + address(0) // PREVIOUS_FACTORY + ); + + // Wrap the factory to allow test vault registration + vaultFactory = new VaultFactoryWrapper(realFactory); + locator.setVaultFactory(address(vaultFactory)); + + // Create vault through pinned beacon proxy + PinnedBeaconProxy vaultProxy = new PinnedBeaconProxy( + address(vaultBeacon), + abi.encodeCall(StakingVault.initialize, (address(this), nodeOperator, address(pdg))) + ); + vault = StakingVault(payable(address(vaultProxy))); + vaultAddress = address(vault); + + // Transfer ownership to vaultOwner + OwnableUpgradeable(vaultAddress).transferOwnership(vaultOwner); + vm.prank(vaultOwner); + vault.acceptOwnership(); + + // Register vault in factory (using test helper) + vaultFactory.registerTestVault(vaultAddress); + + // Register node operator group + operatorGrid.registerGroup(nodeOperator, SHARE_LIMIT); + + // Register tiers for the node operator (tier IDs start from 1, not 0) + TierParams[] memory tierParams = new TierParams[](1); + tierParams[0] = TierParams({ + shareLimit: SHARE_LIMIT, + reserveRatioBP: RESERVE_RATIO_BP, + forcedRebalanceThresholdBP: FORCED_REBALANCE_THRESHOLD_BP, + infraFeeBP: INFRA_FEE_BP, + liquidityFeeBP: LIQUIDITY_FEE_BP, + reservationFeeBP: RESERVATION_FEE_BP + }); + operatorGrid.registerTiers(nodeOperator, tierParams); + + // Connect vault + vm.startPrank(vaultOwner); + // Fund vault with initial balance + vault.fund{value: CONNECT_DEPOSIT}(); + vault.pauseBeaconChainDeposits(); // Vault should start with deposits paused + vault.transferOwnership(address(vaultHub)); + vm.stopPrank(); + + vm.prank(vaultOwner); + vaultHub.connectVault(vaultAddress); + + // Fund vault to ensure it starts healthy + vm.prank(vaultOwner); + vaultHub.fund{value: 50 ether}(vaultAddress); + _applyReport(); + + // Vault should start healthy + wasHealthyBefore = vaultHub.isVaultHealthy(vaultAddress); + require(wasHealthyBefore, "Vault must start healthy"); + lastOpWasSettleFees = false; + + // Deploy handler for invariant testing + handler = new HealthHandler( + vaultHub, + steth, + lazyOracle, + operatorGrid, + vaultAddress, + vaultOwner, + nodeOperator, + this + ); + + // Give handler ETH for operations + vm.deal(address(handler), 1000 ether); + + // Grant handler the REDEMPTION_MASTER_ROLE so it can set liability targets + vaultHub.grantRole(vaultHub.REDEMPTION_MASTER_ROLE(), address(handler)); + + // Setup handler as target for invariant testing + targetContract(address(handler)); + } + + /// ===== INVARIANT TESTS ===== + + /** + * @notice INVARIANT: A healthy vault remains healthy until a new report is produced + * @dev This is the core property from the Certora spec (VaultHub_health.spec line 81-181) + * + * Property: For any vault state where: + * - isVaultHealthy(vault) == true (before call) + * - Method is NOT applyVaultReport or settleLidoFees + * Then: isVaultHealthy(vault) == true (after call) + * + * forge-config: default.invariant.runs = 20000 + * forge-config: default.invariant.depth = 200 + * forge-config: default.invariant.fail-on-revert = false + */ + function invariant_healthyVaultRemainsHealthy() public { + // Get current vault health status (AFTER the handler operation) + bool isHealthyNow = vaultHub.isVaultHealthy(vaultAddress); + + // The invariant: If vault WAS healthy before, it MUST be healthy now + // EXCEPTION: settleLidoFees is explicitly excluded per spec comment: + // "with the exception of settling fees" + if (wasHealthyBefore && !lastOpWasSettleFees) { + assertTrue(isHealthyNow, "INVARIANT VIOLATED: Healthy vault became unhealthy after operation!"); + } + + // Update the "before" state for the next invariant check + wasHealthyBefore = isHealthyNow; + // Reset the settleLidoFees flag (will be set by handler if needed) + lastOpWasSettleFees = false; + } + + /** + * @notice Called after each invariant run - log statistics + */ + function afterInvariant() external view { + console.log("\n=== INVARIANT RUN STATISTICS ==="); + console.log("\n--- OPERATIONS THAT PRESERVE HEALTH ---"); + console.log("Fund calls:", ghost_fundCallCount); + console.log("Mint calls:", ghost_mintCallCount); + console.log("Withdraw calls:", ghost_withdrawCallCount); + console.log("Rebalance calls:", ghost_rebalanceCallCount); + console.log("Force rebalance calls:", ghost_forceRebalanceCallCount); + console.log("Burn shares calls:", ghost_burnSharesCallCount); + console.log("Transfer and burn calls:", ghost_transferAndBurnCallCount); + console.log("Update connection calls:", ghost_updateConnectionCallCount); + console.log("Pause/resume calls:", ghost_pauseResumeCallCount); + console.log("Set liability target calls:", ghost_setLiabilityTargetCallCount); + console.log("Trigger withdrawals calls:", ghost_triggerWithdrawalsCallCount); + console.log("Settle Lido fees calls:", ghost_settleLidoFeesCallCount); + + // Check if vault is healthy + bool isHealthy = vaultHub.isVaultHealthy(vaultAddress); + console.log("\n--- FINAL STATE ---"); + console.log("Vault healthy at end:", isHealthy); + + if (!isHealthy) { + console.log("!!! WARNING: Vault became UNHEALTHY during testing !!!"); + console.log("!!! This violates the invariant if it wasn't due to applyVaultReport or settleLidoFees !!!"); + } + } + + /** + * @notice FUZZ TEST: Healthy vault remains healthy after operations + * @dev Tests the invariant with fuzzing + * forge-config: default.fuzz.runs = 200000 + */ + function testFuzz_healthyVaultRemainsHealthy(uint96 operationType, uint96 param1, uint96 param2) public { + // Ensure vault starts healthy + vm.assume(vaultHub.isVaultHealthy(vaultAddress)); + + // Bound operation type to available operations + operationType = uint96(bound(operationType, 0, 10)); + + // Take snapshot + uint256 snapshot = vm.snapshot(); + + // Execute operation based on type + bool shouldRemainHealthy = true; + + if (operationType == 0) { + // fund + param1 = uint96(bound(param1, 1 ether, 50 ether)); + vm.prank(vaultOwner); + vaultHub.fund{value: param1}(vaultAddress); + _applyReport(); + } else if (operationType == 1) { + // mintShares + uint256 maxMintable = vaultHub.totalMintingCapacityShares(vaultAddress, 0); + param1 = uint96(bound(param1, 0.1 ether, maxMintable)); + if (param1 > 0) { + vm.prank(vaultOwner); + try vaultHub.mintShares(vaultAddress, vaultOwner, param1) { + // Success + } catch { + shouldRemainHealthy = false; // If mint fails, skip check + } + } + } else if (operationType == 2) { + // withdraw + uint256 withdrawable = vaultHub.withdrawableValue(vaultAddress); + if (withdrawable > 0) { + param1 = uint96(bound(param1, 1, withdrawable)); + vm.prank(vaultOwner); + try vaultHub.withdraw(vaultAddress, vaultOwner, param1) { + _applyReport(); + } catch { + shouldRemainHealthy = false; + } + } + } else if (operationType == 3) { + // rebalance (with rounding prevention) + uint256 liability = vaultHub.liabilityShares(vaultAddress); + if (liability > 0 && _canRebalanceSafely()) { + param1 = uint96(bound(param1, 1, liability / 2)); + _applyReport(); + vm.prank(vaultOwner); + try vaultHub.rebalance(vaultAddress, param1) { + // Success + } catch { + shouldRemainHealthy = false; + } + } + } else if (operationType == 4) { + // forceRebalance (with rounding prevention) + if (_canRebalanceSafely()) { + _applyReport(); + try vaultHub.forceRebalance(vaultAddress) { + // Success + } catch { + shouldRemainHealthy = false; + } + } + } else if (operationType == 5) { + // burnShares + uint256 liability = vaultHub.liabilityShares(vaultAddress); + uint256 ownerShares = steth.sharesOf(vaultOwner); + if (liability > 0 && ownerShares > 0) { + uint256 maxBurn = liability < ownerShares ? liability : ownerShares; + param1 = uint96(bound(param1, 1, maxBurn)); + vm.startPrank(vaultOwner); + steth.approve(address(vaultHub), type(uint256).max); + try vaultHub.transferAndBurnShares(vaultAddress, param1) { + // Success + } catch { + shouldRemainHealthy = false; + } + vm.stopPrank(); + } + } else if (operationType == 6) { + // pauseBeaconChainDeposits + vm.prank(vaultOwner); + try vaultHub.pauseBeaconChainDeposits(vaultAddress) { + // Success + } catch { + shouldRemainHealthy = false; + } + } else if (operationType == 7) { + // resumeBeaconChainDeposits + _applyReport(); + vm.prank(vaultOwner); + try vaultHub.resumeBeaconChainDeposits(vaultAddress) { + // Success + } catch { + shouldRemainHealthy = false; + } + } else if (operationType == 8) { + // updateConnection + uint16 newReserveRatioBP = uint16(bound(param1, 1000, 3000)); + uint16 newThresholdBP = uint16(bound(param2, 800, newReserveRatioBP - 100)); + + if (newThresholdBP <= newReserveRatioBP) { + _applyReport(); + vm.prank(address(operatorGrid)); + try + vaultHub.updateConnection( + vaultAddress, + SHARE_LIMIT, + newReserveRatioBP, + newThresholdBP, + INFRA_FEE_BP, + LIQUIDITY_FEE_BP, + RESERVATION_FEE_BP + ) + { + // Success + } catch { + shouldRemainHealthy = false; + } + } + } else if (operationType == 9) { + // setLiabilitySharesTarget + uint256 currentLiability = vaultHub.liabilityShares(vaultAddress); + param1 = uint96(bound(param1, 0, currentLiability)); + vaultHub.grantRole(vaultHub.REDEMPTION_MASTER_ROLE(), address(this)); + try vaultHub.setLiabilitySharesTarget(vaultAddress, param1) { + // Success + } catch { + shouldRemainHealthy = false; + } + } + + // Check if vault remains healthy + if (shouldRemainHealthy) { + bool isHealthyAfter = vaultHub.isVaultHealthy(vaultAddress); + assertTrue(isHealthyAfter, "INVARIANT VIOLATED: Healthy vault became unhealthy after operation!"); + } + + // Revert to snapshot + vm.revertTo(snapshot); + } + + /// --- Helper Functions --- + + /** + * @notice Set flag indicating last operation was settleLidoFees + * @dev Called by handler to mark settleLidoFees operations + * This is public because the handler needs to call it, and making it public + * doesn't break the invariant (we reset it after each check anyway) + */ + function setLastOpWasSettleFees() external { + lastOpWasSettleFees = true; + } + + // Increment functions for ghost variables - called by handler + function incrementFundCount() external { + ghost_fundCallCount++; + } + function incrementMintCount() external { + ghost_mintCallCount++; + } + function incrementWithdrawCount() external { + ghost_withdrawCallCount++; + } + function incrementRebalanceCount() external { + ghost_rebalanceCallCount++; + } + function incrementForceRebalanceCount() external { + ghost_forceRebalanceCallCount++; + } + function incrementBurnSharesCount() external { + ghost_burnSharesCallCount++; + } + function incrementTransferAndBurnCount() external { + ghost_transferAndBurnCallCount++; + } + function incrementUpdateConnectionCount() external { + ghost_updateConnectionCallCount++; + } + function incrementPauseResumeCount() external { + ghost_pauseResumeCallCount++; + } + function incrementSetLiabilityTargetCount() external { + ghost_setLiabilityTargetCallCount++; + } + function incrementTriggerWithdrawalsCount() external { + ghost_triggerWithdrawalsCallCount++; + } + function incrementSettleLidoFeesCount() external { + ghost_settleLidoFeesCallCount++; + } + + function _applyReport() internal { + lazyOracle.refreshReportTimestamp(); + uint256 timestamp = lazyOracle.getTestReportTimestamp(); + + VaultHub.VaultRecord memory record = vaultHub.vaultRecord(vaultAddress); + uint256 totalValue = vaultHub.totalValue(vaultAddress); + + // Get active inOutDelta + uint256 activeIndex = record.inOutDelta[0].refSlot >= record.inOutDelta[1].refSlot ? 0 : 1; + int256 inOutDelta = record.inOutDelta[activeIndex].value; + + // Simulate fee accrual: add 0.01 ETH worth of fees per report (simulates protocol fees) + uint256 newCumulativeFees = record.cumulativeLidoFees + 0.01 ether; + + vm.prank(address(lazyOracle)); + vaultHub.applyVaultReport( + vaultAddress, + timestamp, + totalValue, + inOutDelta, + newCumulativeFees, + record.liabilityShares, + record.maxLiabilityShares, + 0 // slashingReserve + ); + } + + /** + * @notice Check if rebalance can be done safely (prevent rounding issues) + * @dev From spec: require threshold not breached by at least 2 wei + */ + function _canRebalanceSafely() internal view returns (bool) { + uint256 totalValue = vaultHub.totalValue(vaultAddress); + uint256 liabilityShares = vaultHub.liabilityShares(vaultAddress); + VaultHub.VaultConnection memory connection = vaultHub.vaultConnection(vaultAddress); + uint256 thresholdBP = connection.forcedRebalanceThresholdBP; + + // Check share rate > 1 (from spec: require _internalShares() < _internalEth) + if (steth.totalShares() >= steth.totalPooledEther()) { + return false; + } + + // Calculate liability in ETH (round up) + uint256 liabilityEth = steth.getPooledEthBySharesRoundUp(liabilityShares); + + // Check threshold not breached by at least 2 wei + uint256 thresholdValue = (totalValue * (TOTAL_BASIS_POINTS - thresholdBP)) / TOTAL_BASIS_POINTS; + return liabilityEth + 2 < thresholdValue; + } +} + +/** + * @title HealthHandler + * @notice Handler contract for invariant testing of health property + * @dev This handler manages state transitions that should preserve vault health + */ +contract HealthHandler is Test { + VaultHub public vaultHub; + MockStETH public steth; + LazyOracle__HarnessForVaultHub public lazyOracle; + OperatorGrid public operatorGrid; + address public vaultAddress; + address public vaultOwner; + address public nodeOperator; + VaultHubHealthInvariantTest public testContract; + + uint256 constant TOTAL_BASIS_POINTS = 10000; + uint256 constant SHARE_LIMIT = 100 ether; + uint256 constant INFRA_FEE_BP = 500; + uint256 constant LIQUIDITY_FEE_BP = 400; + uint256 constant RESERVATION_FEE_BP = 100; + + constructor( + VaultHub _vaultHub, + MockStETH _steth, + LazyOracle__HarnessForVaultHub _lazyOracle, + OperatorGrid _operatorGrid, + address _vaultAddress, + address _vaultOwner, + address _nodeOperator, + VaultHubHealthInvariantTest _testContract + ) { + vaultHub = _vaultHub; + steth = _steth; + lazyOracle = _lazyOracle; + operatorGrid = _operatorGrid; + vaultAddress = _vaultAddress; + vaultOwner = _vaultOwner; + nodeOperator = _nodeOperator; + testContract = _testContract; + } + + /// ========== OPERATIONS THAT MUST PRESERVE HEALTH ========== + + /** + * @notice Fund the vault with ETH + */ + function fund(uint96 amount) external { + amount = uint96(bound(amount, 1 ether, 50 ether)); + + vm.prank(vaultOwner); + vaultHub.fund{value: amount}(vaultAddress); + + _applyReport(); + testContract.incrementFundCount(); + } + + /** + * @notice Mint shares from the vault + */ + function mintShares(uint96 shares) external { + shares = uint96(bound(shares, 0.1 ether, 10 ether)); + + uint256 maxMintable = vaultHub.totalMintingCapacityShares(vaultAddress, 0); + if (shares > maxMintable) shares = uint96(maxMintable); + if (shares == 0) return; + + vm.prank(vaultOwner); + try vaultHub.mintShares(vaultAddress, vaultOwner, shares) { + testContract.incrementMintCount(); + } catch { + // Minting can fail, that's OK + } + } + + /** + * @notice Withdraw funds from the vault + */ + function withdraw(uint96 amount) external { + uint256 withdrawable = vaultHub.withdrawableValue(vaultAddress); + if (withdrawable == 0) return; + + amount = uint96(bound(amount, 1, withdrawable)); + + vm.prank(vaultOwner); + try vaultHub.withdraw(vaultAddress, vaultOwner, amount) { + _applyReport(); + testContract.incrementWithdrawCount(); + } catch { + // Withdrawal can fail, that's OK + } + } + + /** + * @notice Rebalance vault (reduces liability) + * @dev With rounding prevention per spec + */ + function rebalance(uint96 shares) external { + // Check rounding prevention requirement + if (!_canRebalanceSafely()) return; + + uint256 liability = vaultHub.liabilityShares(vaultAddress); + if (liability == 0) return; + + shares = uint96(bound(shares, 1, liability / 2)); + + _applyReport(); + + vm.prank(vaultOwner); + try vaultHub.rebalance(vaultAddress, shares) { + testContract.incrementRebalanceCount(); + } catch { + // Rebalance can fail, that's OK + } + } + + /** + * @notice Force rebalance when vault is unhealthy + * @dev With rounding prevention per spec + */ + function forceRebalance() external { + // Check rounding prevention requirement + if (!_canRebalanceSafely()) return; + + _applyReport(); + + try vaultHub.forceRebalance(vaultAddress) { + testContract.incrementForceRebalanceCount(); + } catch { + // Force rebalance can fail, that's OK + } + } + + /** + * @notice Burn shares - reduces liability + */ + function burnShares(uint96 shares) external { + uint256 liability = vaultHub.liabilityShares(vaultAddress); + uint256 ownerShares = steth.sharesOf(vaultOwner); + + if (liability == 0 || ownerShares == 0) return; + + uint256 maxBurn = liability < ownerShares ? liability : ownerShares; + shares = uint96(bound(shares, 1, maxBurn)); + + vm.startPrank(vaultOwner); + steth.approve(address(vaultHub), type(uint256).max); + try vaultHub.transferAndBurnShares(vaultAddress, shares) { + testContract.incrementBurnSharesCount(); + testContract.incrementTransferAndBurnCount(); + } catch { + // Burning can fail, that's OK + } + vm.stopPrank(); + } + + /** + * @notice Update vault connection parameters + * @dev Requires forcedRebalanceThresholdBP <= reserveRatioBP + */ + function updateConnection(uint16 newReserveRatioBP, uint16 newThresholdBP) external { + newReserveRatioBP = uint16(bound(newReserveRatioBP, 1000, 3000)); // 10% to 30% + newThresholdBP = uint16(bound(newThresholdBP, 800, newReserveRatioBP - 100)); + + // Enforce requirement from spec + if (newThresholdBP > newReserveRatioBP) return; + + _applyReport(); + + vm.prank(address(operatorGrid)); + try + vaultHub.updateConnection( + vaultAddress, + SHARE_LIMIT, + newReserveRatioBP, + newThresholdBP, + INFRA_FEE_BP, + LIQUIDITY_FEE_BP, + RESERVATION_FEE_BP + ) + { + testContract.incrementUpdateConnectionCount(); + } catch { + // Update can fail, that's OK + } + } + + /** + * @notice Pause beacon chain deposits + */ + function pauseBeaconChainDeposits() external { + vm.prank(vaultOwner); + try vaultHub.pauseBeaconChainDeposits(vaultAddress) { + testContract.incrementPauseResumeCount(); + } catch { + // Pause can fail if already paused, that's OK + } + } + + /** + * @notice Resume beacon chain deposits + */ + function resumeBeaconChainDeposits() external { + _applyReport(); + + vm.prank(vaultOwner); + try vaultHub.resumeBeaconChainDeposits(vaultAddress) { + testContract.incrementPauseResumeCount(); + } catch { + // Resume can fail if already resumed, that's OK + } + } + + /** + * @notice Set liability shares target (redemption mechanism) + */ + function setLiabilitySharesTarget(uint96 target) external { + uint256 currentLiability = vaultHub.liabilityShares(vaultAddress); + target = uint96(bound(target, 0, currentLiability)); + + try vaultHub.setLiabilitySharesTarget(vaultAddress, target) { + testContract.incrementSetLiabilityTargetCount(); + } catch { + // Can fail if target is invalid, that's OK + } + } + + /** + * @notice Settle Lido fees + * @dev EXCEPTION: Per spec, this CAN make a healthy vault unhealthy + * "with the exception of settling fees" + */ + function settleLidoFees() external { + _applyReport(); + + if (vaultHub.settleableLidoFeesValue(vaultAddress) > 0) { + // Mark that this operation is settleLidoFees (excluded from invariant) + testContract.setLastOpWasSettleFees(); + + try vaultHub.settleLidoFees(vaultAddress) { + testContract.incrementSettleLidoFeesCount(); + } catch { + // Settlement can fail, that's OK + } + } + } + + /** + * @notice Trigger validator withdrawals + * @dev Simplified version - actual implementation would need validator data + */ + function triggerValidatorWithdrawals() external { + // This is a complex operation that requires validator data + // For now, we'll skip it or implement a simplified version + // In a full implementation, you'd need to provide proper validator withdrawal data + testContract.incrementTriggerWithdrawalsCount(); + } + + /** + * @notice Check if rebalance can be done safely (prevent rounding issues) + */ + function _canRebalanceSafely() internal view returns (bool) { + uint256 totalValue = vaultHub.totalValue(vaultAddress); + uint256 liabilityShares = vaultHub.liabilityShares(vaultAddress); + VaultHub.VaultConnection memory connection = vaultHub.vaultConnection(vaultAddress); + uint256 thresholdBP = connection.forcedRebalanceThresholdBP; + + // Check share rate > 1 (from spec: require _internalShares() < _internalEth) + if (steth.totalShares() >= steth.totalPooledEther()) { + return false; + } + + // Calculate liability in ETH (round up) + uint256 liabilityEth = steth.getPooledEthBySharesRoundUp(liabilityShares); + + // Check threshold not breached by at least 2 wei + uint256 thresholdValue = (totalValue * (TOTAL_BASIS_POINTS - thresholdBP)) / TOTAL_BASIS_POINTS; + return liabilityEth + 2 < thresholdValue; + } + + function _applyReport() internal { + lazyOracle.refreshReportTimestamp(); + uint256 timestamp = lazyOracle.getTestReportTimestamp(); + + VaultHub.VaultRecord memory record = vaultHub.vaultRecord(vaultAddress); + uint256 totalValue = vaultHub.totalValue(vaultAddress); + + uint256 activeIndex = record.inOutDelta[0].refSlot >= record.inOutDelta[1].refSlot ? 0 : 1; + int256 inOutDelta = record.inOutDelta[activeIndex].value; + + // Simulate fee accrual: add 0.01 ETH worth of fees per report (simulates protocol fees) + uint256 newCumulativeFees = record.cumulativeLidoFees + 0.01 ether; + + vm.prank(address(lazyOracle)); + vaultHub.applyVaultReport( + vaultAddress, + timestamp, + totalValue, + inOutDelta, + newCumulativeFees, + record.liabilityShares, + record.maxLiabilityShares, + 0 + ); + } +} diff --git a/test/0.8.25/vaults/vaulthub/VaultHubInvariant.t.sol b/test/0.8.25/vaults/vaulthub/VaultHubInvariant.t.sol new file mode 100644 index 0000000000..da07b567c5 --- /dev/null +++ b/test/0.8.25/vaults/vaulthub/VaultHubInvariant.t.sol @@ -0,0 +1,320 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity 0.8.25; + +import {IStakingVault} from "contracts/0.8.25/vaults/interfaces/IStakingVault.sol"; +import {ILidoLocator} from "contracts/common/interfaces/ILidoLocator.sol"; +import {IHashConsensus} from "contracts/common/interfaces/IHashConsensus.sol"; + +// ============ MOCKS ============ + +contract MockStETH { + uint256 public totalShares; + uint256 public totalPooledEther; + mapping(address => uint256) public shares; + mapping(address => mapping(address => uint256)) public allowances; + + constructor() { + // Start with 1:1 ratio but can be changed + totalPooledEther = 1 ether; + totalShares = 1 ether; + } + + /** + * @notice Set total pooled ether while keeping shares constant + * @dev This simulates staking rewards accumulating, changing the share rate + */ + function setTotalPooledEther(uint256 _amount) external { + totalPooledEther = _amount; + // Do NOT update totalShares - this creates a variable rate! + } + + /** + * @notice Simulate a rebalance that increases total pooled ether + * @dev This is what happens when a vault rebalances - adds ETH without changing shares + */ + function simulateRebalanceRateIncrease(uint256 _ethToAdd) external { + totalPooledEther += _ethToAdd; + // totalShares stays the same - rate increases! + } + + /** + * @notice Set a specific share rate (multiplied by 100 for precision) + * @param _rateBP Rate in basis points where 10000 = 1.0x, 15000 = 1.5x, 20000 = 2.0x + * @dev Example: _rateBP = 12000 means 1 share = 1.2 ETH + */ + function setShareRateBP(uint256 _rateBP) external { + require(_rateBP >= 10000 && _rateBP <= 20000, "Rate must be between 1.0x and 2.0x"); + // If we have 1000 shares and want rate 1.2x, we need 1200 ETH + totalPooledEther = (totalShares * _rateBP) / 10000; + } + + /** + * @notice Set initial total shares (for setup only) + */ + function setInitialShares(uint256 _shares) external { + require(totalShares == 1 ether, "Can only set initial shares once"); + totalShares = _shares; + totalPooledEther = _shares; // Start at 1:1, then adjust with setShareRateBP + } + + function getSharesByPooledEth(uint256 _ethAmount) public view returns (uint256) { + if (totalPooledEther == 0) return 0; + return (_ethAmount * totalShares) / totalPooledEther; + } + + function getPooledEthByShares(uint256 _sharesAmount) public view returns (uint256) { + return (_sharesAmount * totalPooledEther) / totalShares; + } + + function getPooledEthBySharesRoundUp(uint256 _sharesAmount) public view returns (uint256) { + return (_sharesAmount * totalPooledEther + totalShares - 1) / totalShares; + } + + function getTotalShares() external view returns (uint256) { + return totalShares; + } + + function sharesOf(address account) external view returns (uint256) { + return shares[account]; + } + + function balanceOf(address account) external view returns (uint256) { + return getPooledEthByShares(shares[account]); + } + + function mintExternalShares(address _recipient, uint256 _sharesAmount) external { + shares[_recipient] += _sharesAmount; + totalShares += _sharesAmount; + } + + function burnExternalShares(uint256 _sharesAmount) external { + shares[msg.sender] -= _sharesAmount; + totalShares -= _sharesAmount; + } + + function approve(address spender, uint256 amount) external returns (bool) { + allowances[msg.sender][spender] = amount; + return true; + } + + function transfer(address to, uint256 amount) external returns (bool) { + uint256 sharesToTransfer = getSharesByPooledEth(amount); + require(shares[msg.sender] >= sharesToTransfer, "Insufficient shares"); + shares[msg.sender] -= sharesToTransfer; + shares[to] += sharesToTransfer; + return true; + } + + function transferSharesFrom(address from, address to, uint256 _sharesAmount) external returns (uint256) { + require(shares[from] >= _sharesAmount, "Insufficient shares"); + + // Check and update allowance if not infinite + uint256 currentAllowance = allowances[from][msg.sender]; + if (currentAllowance != type(uint256).max) { + require(currentAllowance >= getPooledEthByShares(_sharesAmount), "Insufficient allowance"); + allowances[from][msg.sender] = currentAllowance - getPooledEthByShares(_sharesAmount); + } + + shares[from] -= _sharesAmount; + shares[to] += _sharesAmount; + return _sharesAmount; + } + + function rebalanceExternalEtherToInternal(uint256) external payable { + totalPooledEther += msg.value; + } +} + +contract MockLidoLocator is ILidoLocator { + address public override lido; + address public override predepositGuarantee; + address public override lazyOracle; + address public override operatorGrid; + address public override treasury; + address public override accounting; + address public override vaultHub; + address public override vaultFactory; + + constructor( + address _lido, + address _pdg, + address _lazyOracle, + address _operatorGrid, + address _treasury, + address _accounting + ) { + lido = _lido; + predepositGuarantee = _pdg; + lazyOracle = _lazyOracle; + operatorGrid = _operatorGrid; + treasury = _treasury; + accounting = _accounting; + } + + function setOperatorGrid(address _operatorGrid) external { + operatorGrid = _operatorGrid; + } + + function setLazyOracle(address _lazyOracle) external { + lazyOracle = _lazyOracle; + } + + function accountingOracle() external pure returns (address) { + return address(0); + } + function depositSecurityModule() external pure returns (address) { + return address(0); + } + function elRewardsVault() external pure returns (address) { + return address(0); + } + function oracleReportSanityChecker() external pure returns (address) { + return address(0); + } + function burner() external pure returns (address) { + return address(0); + } + function stakingRouter() external pure returns (address) { + return address(0); + } + function validatorsExitBusOracle() external pure returns (address) { + return address(0); + } + function withdrawalQueue() external pure returns (address) { + return address(0); + } + function withdrawalVault() external pure returns (address) { + return address(0); + } + function postTokenRebaseReceiver() external pure returns (address) { + return address(0); + } + function oracleDaemonConfig() external pure returns (address) { + return address(0); + } + function wstETH() external pure returns (address) { + return address(0); + } + + function coreComponents() external pure returns (address, address, address, address, address, address) { + return (address(0), address(0), address(0), address(0), address(0), address(0)); + } + + function oracleReportComponents() + external + pure + returns (address, address, address, address, address, address, address) + { + return (address(0), address(0), address(0), address(0), address(0), address(0), address(0)); + } + + function setVaultHub(address _vaultHub) external { + vaultHub = _vaultHub; + } + + function setVaultFactory(address _vaultFactory) external { + vaultFactory = _vaultFactory; + } +} + +contract MockHashConsensus is IHashConsensus { + uint256 private currentRefSlot = 1000; + + function getIsMember(address) external pure returns (bool) { + return true; + } + + function getCurrentFrame() external view returns (uint256, uint256) { + return (currentRefSlot, currentRefSlot + 100); + } + + function getChainConfig() + external + pure + returns (uint256 slotsPerEpoch, uint256 secondsPerSlot, uint256 genesisTime) + { + return (32, 12, 1606824000); + } + + function getFrameConfig() external pure returns (uint256 initialEpoch, uint256 epochsPerFrame) { + return (0, 225); + } + + function getInitialRefSlot() external pure returns (uint256) { + return 0; + } + + function incrementRefSlot() external { + currentRefSlot += 100; + } +} + +contract MockLazyOracle { + uint256 private reportTimestamp; + + constructor() { + reportTimestamp = block.timestamp; + } + + function refreshReportTimestamp() external { + reportTimestamp = block.timestamp; + } + + function latestReportTimestamp() external view returns (uint256) { + return reportTimestamp; + } + + function removeVaultQuarantine(address) external {} +} + +contract MockVaultFactory { + mapping(address => bool) private deployedVaultsMap; + + function registerVault(address vault) external { + deployedVaultsMap[vault] = true; + } + + function deployedVaults(address vault) external view returns (bool) { + return deployedVaultsMap[vault]; + } +} + +contract MockPredepositGuarantee { + function pendingActivations(IStakingVault) external pure returns (uint256) { + return 0; + } +} + +contract MockDepositContract { + function get_deposit_root() external pure returns (bytes32) { + return bytes32(0); + } +} + +contract MockProxy { + address private immutable _implementation; + + constructor(address implementation_) { + _implementation = implementation_; + } + + fallback() external payable { + address impl = _implementation; + assembly { + calldatacopy(0, 0, calldatasize()) + let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0) + returndatacopy(0, 0, returndatasize()) + switch result + case 0 { + revert(0, returndatasize()) + } + default { + return(0, returndatasize()) + } + } + } + + receive() external payable {} +} diff --git a/test/0.8.25/vaults/vaulthub/VaultHubInvariantLockedCoversLiabilityAndReserve.t.sol b/test/0.8.25/vaults/vaulthub/VaultHubInvariantLockedCoversLiabilityAndReserve.t.sol new file mode 100644 index 0000000000..a73baf9ecc --- /dev/null +++ b/test/0.8.25/vaults/vaulthub/VaultHubInvariantLockedCoversLiabilityAndReserve.t.sol @@ -0,0 +1,701 @@ +// SPDX-License-Identifier: UNLICENSED + +pragma solidity 0.8.25; + +import "forge-std/Test.sol"; +import "forge-std/console.sol"; + +import {VaultHub} from "contracts/0.8.25/vaults/VaultHub.sol"; +import {StakingVault} from "contracts/0.8.25/vaults/StakingVault.sol"; +import {OperatorGrid, TierParams} from "contracts/0.8.25/vaults/OperatorGrid.sol"; +import {IStakingVault} from "contracts/0.8.25/vaults/interfaces/IStakingVault.sol"; +import {ILidoLocator} from "contracts/common/interfaces/ILidoLocator.sol"; +import {ILido} from "contracts/common/interfaces/ILido.sol"; +import {IHashConsensus} from "contracts/common/interfaces/IHashConsensus.sol"; +import {IDepositContract} from "contracts/common/interfaces/IDepositContract.sol"; + +// OpenZeppelin contracts for proxy pattern +import {UpgradeableBeacon} from "@openzeppelin/contracts-v5.2/proxy/beacon/UpgradeableBeacon.sol"; +import {PinnedBeaconProxy} from "contracts/0.8.25/vaults/PinnedBeaconProxy.sol"; +import {OwnableUpgradeable} from "contracts/openzeppelin/5.2/upgradeable/access/OwnableUpgradeable.sol"; +import { + TransparentUpgradeableProxy +} from "@openzeppelin/contracts-v5.2/proxy/transparent/TransparentUpgradeableProxy.sol"; + +// Import test harnesses and mocks +import {LazyOracle__HarnessForVaultHub} from "./contracts/LazyOracle__HarnessForVaultHub.sol"; +import {VaultFactory} from "contracts/0.8.25/vaults/VaultFactory.sol"; +import {VaultFactoryWrapper} from "./contracts/VaultFactoryWrapper.sol"; +import {MinimalDashboard} from "./contracts/MinimalDashboard.sol"; +import { + MockStETH, + MockLidoLocator, + MockHashConsensus, + MockPredepositGuarantee, // never used but needed + MockDepositContract // prevent conflict with different version +} from "./VaultHubInvariant.t.sol"; + +contract VaultHubLockedInvariantTest is Test { + // Constants + uint256 constant TOTAL_BASIS_POINTS = 10000; + uint256 constant CONNECT_DEPOSIT = 1 ether; + uint256 constant INITIAL_LIDO_BALANCE = 10000 ether; + + // Test parameters + uint256 constant SHARE_LIMIT = 100 ether; + uint256 constant RESERVE_RATIO_BP = 2000; // 20% + uint256 constant FORCED_REBALANCE_THRESHOLD_BP = 1800; // 18% + uint256 constant INFRA_FEE_BP = 500; + uint256 constant LIQUIDITY_FEE_BP = 400; + uint256 constant RESERVATION_FEE_BP = 100; + uint256 constant MAX_RELATIVE_SHARE_LIMIT_BP = 1000; // 10% + + // Core contracts - REAL + VaultHub public vaultHub; + OperatorGrid public operatorGrid; + StakingVault public vault; + + // Core contracts - MOCKED (external dependencies) + MockStETH public steth; + MockLidoLocator public locator; + MockHashConsensus public consensus; + LazyOracle__HarnessForVaultHub public lazyOracle; + VaultFactoryWrapper public vaultFactory; + MockPredepositGuarantee public pdg; // never used + MockDepositContract public depositContract; + + // Beacon and proxy infrastructure for StakingVault + UpgradeableBeacon public vaultBeacon; + + // Test addresses + address public vaultAddress; + address public vaultOwner; + address public nodeOperator; + + // Handler for invariant testing + LockedHandler public handler; + + function setUp() public { + vaultOwner = makeAddr("vaultOwner"); + nodeOperator = makeAddr("nodeOperator"); + + // Give test contract and vault owner enough ETH for testing + vm.deal(address(this), 1000 ether); + vm.deal(vaultOwner, 1000 ether); + + // Deploy mock dependencies + steth = new MockStETH(); + vm.deal(address(steth), INITIAL_LIDO_BALANCE); + + // Initialize with realistic total shares to simulate a mature protocol + steth.setInitialShares(INITIAL_LIDO_BALANCE); + + // Set a realistic share rate (1.15x - simulates accumulated staking rewards) + steth.setShareRateBP(11500); // 1.15x rate + + consensus = new MockHashConsensus(); + depositContract = new MockDepositContract(); + pdg = new MockPredepositGuarantee(); + + // Create a treasury address for fee settlements + address treasury = makeAddr("treasury"); + + // Deploy locator (temporarily without OperatorGrid and LazyOracle) + locator = new MockLidoLocator( + address(steth), + address(pdg), + address(0), // lazyOracle - will be set later + address(0), // operatorGrid - will be set later + treasury, + address(0) // accounting + ); + + // Deploy REAL LazyOracle via harness and proxy + LazyOracle__HarnessForVaultHub lazyOracleImpl = new LazyOracle__HarnessForVaultHub(address(locator)); + TransparentUpgradeableProxy lazyOracleProxy = new TransparentUpgradeableProxy( + address(lazyOracleImpl), + address(this), + "" + ); + lazyOracle = LazyOracle__HarnessForVaultHub(address(lazyOracleProxy)); + + // Initialize with sanity params + lazyOracle.initialize( + address(this), // admin + 7 days, // quarantine period + 1000, // maxRewardRatioBP (10%) + 1 ether // maxLidoFeeRatePerSecond + ); + + // Update locator with LazyOracle + locator.setLazyOracle(address(lazyOracle)); + + // Deploy REAL OperatorGrid via proxy + OperatorGrid operatorGridImpl = new OperatorGrid(ILidoLocator(address(locator))); + TransparentUpgradeableProxy operatorGridProxy = new TransparentUpgradeableProxy( + address(operatorGridImpl), + address(this), // admin + "" // no initialization data here, call initialize separately + ); + operatorGrid = OperatorGrid(address(operatorGridProxy)); + + // Initialize OperatorGrid with default tier params + TierParams memory defaultTierParams = TierParams({ + shareLimit: SHARE_LIMIT, + reserveRatioBP: RESERVE_RATIO_BP, + forcedRebalanceThresholdBP: FORCED_REBALANCE_THRESHOLD_BP, + infraFeeBP: INFRA_FEE_BP, + liquidityFeeBP: LIQUIDITY_FEE_BP, + reservationFeeBP: RESERVATION_FEE_BP + }); + operatorGrid.initialize(address(this), defaultTierParams); + operatorGrid.grantRole(operatorGrid.REGISTRY_ROLE(), address(this)); + + // Update locator with OperatorGrid + locator.setOperatorGrid(address(operatorGrid)); + + // Deploy VaultHub implementation + VaultHub vaultHubImpl = new VaultHub( + ILidoLocator(address(locator)), + ILido(address(steth)), + consensus, + MAX_RELATIVE_SHARE_LIMIT_BP + ); + + // Deploy proxy and initialize through it + TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( + address(vaultHubImpl), + address(this), // admin + "" // no initialization data here, call initialize separately + ); + vaultHub = VaultHub(payable(address(proxy))); + + // Initialize VaultHub + vaultHub.initialize(address(this)); + + // Grant pause/resume roles + vaultHub.grantRole(vaultHub.PAUSE_ROLE(), address(this)); + vaultHub.grantRole(vaultHub.RESUME_ROLE(), address(this)); + + // Proxy storage doesn't inherit the paused state from implementation constructor + // We need to pause first, then resume to get to the correct state + vaultHub.pauseFor(365 days); + vaultHub.resume(); + + // Update locator with VaultHub address + locator.setVaultHub(address(vaultHub)); + + // Deploy REAL StakingVault implementation and beacon FIRST (needed for factory) + StakingVault vaultImpl = new StakingVault(address(depositContract)); + vaultBeacon = new UpgradeableBeacon(address(vaultImpl), address(this)); + + // Deploy REAL VaultFactory, then wrap it to intercept deployedVaults() calls + MinimalDashboard dashboardImpl = new MinimalDashboard(); + VaultFactory realFactory = new VaultFactory( + address(locator), // LIDO_LOCATOR + address(vaultBeacon), // BEACON + address(dashboardImpl), // DASHBOARD_IMPL + address(0) // PREVIOUS_FACTORY + ); + + // Wrap the factory to allow test vault registration + vaultFactory = new VaultFactoryWrapper(realFactory); + locator.setVaultFactory(address(vaultFactory)); + + // Create vault through pinned beacon proxy + PinnedBeaconProxy vaultProxy = new PinnedBeaconProxy( + address(vaultBeacon), + abi.encodeCall(StakingVault.initialize, (address(this), nodeOperator, address(pdg))) + ); + vault = StakingVault(payable(address(vaultProxy))); + vaultAddress = address(vault); + + // Transfer ownership to vaultOwner + OwnableUpgradeable(vaultAddress).transferOwnership(vaultOwner); + vm.prank(vaultOwner); + vault.acceptOwnership(); + + // Register vault in factory (using test helper) + vaultFactory.registerTestVault(vaultAddress); + + // Register node operator group + operatorGrid.registerGroup(nodeOperator, SHARE_LIMIT); + + // Register tiers for the node operator (tier IDs start from 1, not 0) + TierParams[] memory tierParams = new TierParams[](1); + tierParams[0] = TierParams({ + shareLimit: SHARE_LIMIT, + reserveRatioBP: RESERVE_RATIO_BP, + forcedRebalanceThresholdBP: FORCED_REBALANCE_THRESHOLD_BP, + infraFeeBP: INFRA_FEE_BP, + liquidityFeeBP: LIQUIDITY_FEE_BP, + reservationFeeBP: RESERVATION_FEE_BP + }); + operatorGrid.registerTiers(nodeOperator, tierParams); + + // Connect vault + vm.startPrank(vaultOwner); + // Fund vault with initial balance + vault.fund{value: CONNECT_DEPOSIT}(); + vault.pauseBeaconChainDeposits(); // Vault should start with deposits paused + vault.transferOwnership(address(vaultHub)); + vm.stopPrank(); + + vm.prank(vaultOwner); + vaultHub.connectVault(vaultAddress); + + // Fund vault to ensure it starts with sufficient locked value + vm.prank(vaultOwner); + vaultHub.fund{value: 50 ether}(vaultAddress); + _applyReport(); + + // Deploy handler for invariant testing + handler = new LockedHandler( + vaultHub, + steth, + lazyOracle, + operatorGrid, + vaultAddress, + vaultOwner, + nodeOperator, + this + ); + + // Give handler ETH for operations + vm.deal(address(handler), 1000 ether); + + // Grant handler the REDEMPTION_MASTER_ROLE so it can set liability targets + vaultHub.grantRole(vaultHub.REDEMPTION_MASTER_ROLE(), address(handler)); + + // Setup handler as target for invariant testing + targetContract(address(handler)); + } + + /// ===== INVARIANT TESTS ===== + + /** + * @notice INVARIANT: Locked value covers liability and reserve + * @dev Core property from vaultLockedCoversLiabilityAndReserve (Accounting.spec line 37-99) + * + * Property: For any vault state: + * locked(vault) * (TOTAL_BASIS_POINTS - reserveRatioBP) >= liabilityEth * TOTAL_BASIS_POINTS + * + * This ensures the vault always has sufficient locked value to cover both: + * 1. The liability (stETH minted to users) + * 2. The required reserve ratio + * + * forge-config: default.invariant.runs = 10000 + * forge-config: default.invariant.depth = 100 + * forge-config: default.invariant.fail-on-revert = false + */ + function invariant_lockedCoversLiabilityAndReserve() public { + uint256 locked = vaultHub.locked(vaultAddress); + uint256 liabilityShares = vaultHub.liabilityShares(vaultAddress); + uint256 liabilityEth = steth.getPooledEthByShares(liabilityShares); + + VaultHub.VaultConnection memory connection = vaultHub.vaultConnection(vaultAddress); + uint256 reserveRatioBP = connection.reserveRatioBP; + + // locked * (TOTAL_BASIS_POINTS - reserveRatioBP) >= liabilityEth * TOTAL_BASIS_POINTS + uint256 lhs = locked * (TOTAL_BASIS_POINTS - reserveRatioBP); + uint256 rhs = liabilityEth * TOTAL_BASIS_POINTS; + + assertGe(lhs, rhs, "INVARIANT VIOLATED: locked amount does not cover liability and reserve"); + } + + /** + * @notice Called after each invariant run - log statistics + */ + function afterInvariant() external view { + console.log("\n=== INVARIANT RUN STATISTICS ==="); + console.log("\n--- OPERATIONS (Success/Fail/Total) ---"); + + uint256 fundSuccess = handler.ghost_fundCallCount(); + uint256 fundFail = handler.ghost_fundFailCount(); + console.log("Fund: %d / %d / %d", fundSuccess, fundFail, fundSuccess + fundFail); + + uint256 mintSuccess = handler.ghost_mintCallCount(); + uint256 mintFail = handler.ghost_mintFailCount(); + console.log("Mint: %d / %d / %d", mintSuccess, mintFail, mintSuccess + mintFail); + + uint256 withdrawSuccess = handler.ghost_withdrawCallCount(); + uint256 withdrawFail = handler.ghost_withdrawFailCount(); + console.log("Withdraw: %d / %d / %d", withdrawSuccess, withdrawFail, withdrawSuccess + withdrawFail); + + uint256 rebalanceSuccess = handler.ghost_rebalanceCallCount(); + uint256 rebalanceFail = handler.ghost_rebalanceFailCount(); + console.log("Rebalance: %d / %d / %d", rebalanceSuccess, rebalanceFail, rebalanceSuccess + rebalanceFail); + + uint256 burnSuccess = handler.ghost_burnSharesCallCount(); + uint256 burnFail = handler.ghost_burnSharesFailCount(); + console.log("Burn shares: %d / %d / %d", burnSuccess, burnFail, burnSuccess + burnFail); + + uint256 shareRateSuccess = handler.ghost_shareRateIncreaseCallCount(); + console.log("Share rate increase: %d / 0 / %d", shareRateSuccess, shareRateSuccess); + + uint256 updateSuccess = handler.ghost_updateConnectionCallCount(); + uint256 updateFail = handler.ghost_updateConnectionFailCount(); + console.log("Update connection: %d / %d / %d", updateSuccess, updateFail, updateSuccess + updateFail); + + uint256 liabilitySuccess = handler.ghost_setLiabilityTargetCallCount(); + uint256 liabilityFail = handler.ghost_setLiabilityTargetFailCount(); + console.log( + "Set liability target: %d / %d / %d", + liabilitySuccess, + liabilityFail, + liabilitySuccess + liabilityFail + ); + + uint256 reportSuccess = handler.ghost_applyReportCallCount(); + console.log("Apply report: %d / 0 / %d", reportSuccess, reportSuccess); + + uint256 settleSuccess = handler.ghost_settleFeesCallCount(); + uint256 settleFail = handler.ghost_settleFeesFailCount(); + console.log("Settle fees: %d / %d / %d", settleSuccess, settleFail, settleSuccess + settleFail); + + // Calculate success rates + console.log("\n--- SUCCESS RATES ---"); + console.log("Fund: %d%%", _successRate(fundSuccess, fundFail)); + console.log("Mint: %d%%", _successRate(mintSuccess, mintFail)); + console.log("Withdraw: %d%%", _successRate(withdrawSuccess, withdrawFail)); + console.log("Rebalance: %d%%", _successRate(rebalanceSuccess, rebalanceFail)); + console.log("Burn shares: %d%%", _successRate(burnSuccess, burnFail)); + console.log("Update connection: %d%%", _successRate(updateSuccess, updateFail)); + console.log("Set liability target: %d%%", _successRate(liabilitySuccess, liabilityFail)); + console.log("Settle fees: %d%%", _successRate(settleSuccess, settleFail)); + } + + function _successRate(uint256 success, uint256 fail) internal pure returns (uint256) { + uint256 total = success + fail; + if (total == 0) return 0; + return (success * 100) / total; + } + + function _applyReport() internal { + lazyOracle.refreshReportTimestamp(); + uint256 timestamp = lazyOracle.getTestReportTimestamp(); + + VaultHub.VaultRecord memory record = vaultHub.vaultRecord(vaultAddress); + uint256 totalValue = vaultHub.totalValue(vaultAddress); + + // Get active inOutDelta + uint256 activeIndex = record.inOutDelta[0].refSlot >= record.inOutDelta[1].refSlot ? 0 : 1; + int256 inOutDelta = record.inOutDelta[activeIndex].value; + + vm.prank(address(lazyOracle)); + vaultHub.applyVaultReport( + vaultAddress, + timestamp, + totalValue, + inOutDelta, + record.cumulativeLidoFees, + record.liabilityShares, + record.maxLiabilityShares, + 0 // slashingReserve + ); + } +} + +/** + * @title LockedHandler + * @notice Handler contract for invariant testing of locked value coverage + * @dev This handler manages state transitions that should preserve the locked invariant + */ +contract LockedHandler is Test { + VaultHub public vaultHub; + MockStETH public steth; + LazyOracle__HarnessForVaultHub public lazyOracle; + OperatorGrid public operatorGrid; + address public vaultAddress; + address public vaultOwner; + address public nodeOperator; + VaultHubLockedInvariantTest public testContract; + + uint256 constant TOTAL_BASIS_POINTS = 10000; + uint256 constant SHARE_LIMIT = 100 ether; + uint256 constant INFRA_FEE_BP = 500; + uint256 constant LIQUIDITY_FEE_BP = 400; + uint256 constant RESERVATION_FEE_BP = 100; + + // Ghost variables to track successes + uint256 public ghost_fundCallCount; + uint256 public ghost_mintCallCount; + uint256 public ghost_withdrawCallCount; + uint256 public ghost_rebalanceCallCount; + uint256 public ghost_burnSharesCallCount; + uint256 public ghost_shareRateIncreaseCallCount; + uint256 public ghost_updateConnectionCallCount; + uint256 public ghost_setLiabilityTargetCallCount; + uint256 public ghost_applyReportCallCount; + uint256 public ghost_settleFeesCallCount; + + // Ghost variables to track failures + uint256 public ghost_fundFailCount; + uint256 public ghost_mintFailCount; + uint256 public ghost_withdrawFailCount; + uint256 public ghost_rebalanceFailCount; + uint256 public ghost_burnSharesFailCount; + uint256 public ghost_updateConnectionFailCount; + uint256 public ghost_setLiabilityTargetFailCount; + uint256 public ghost_settleFeesFailCount; + + constructor( + VaultHub _vaultHub, + MockStETH _steth, + LazyOracle__HarnessForVaultHub _lazyOracle, + OperatorGrid _operatorGrid, + address _vaultAddress, + address _vaultOwner, + address _nodeOperator, + VaultHubLockedInvariantTest _testContract + ) { + vaultHub = _vaultHub; + steth = _steth; + lazyOracle = _lazyOracle; + operatorGrid = _operatorGrid; + vaultAddress = _vaultAddress; + vaultOwner = _vaultOwner; + nodeOperator = _nodeOperator; + testContract = _testContract; + } + + /// ========== OPERATIONS THAT MUST PRESERVE LOCKED INVARIANT ========== + + /** + * @notice Fund the vault with ETH + */ + function fund(uint96 amount) external { + amount = uint96(bound(amount, 1 ether, 50 ether)); + + vm.prank(vaultOwner); + try vaultHub.fund{value: amount}(vaultAddress) { + _applyReport(); + ghost_fundCallCount++; + } catch { + ghost_fundFailCount++; + } + } + + /** + * @notice Mint shares from the vault + */ + function mintShares(uint96 shares) external { + shares = uint96(bound(shares, 0.1 ether, 10 ether)); + + uint256 maxMintable = vaultHub.totalMintingCapacityShares(vaultAddress, 0); + if (shares > maxMintable) shares = uint96(maxMintable); + if (shares == 0) { + ghost_mintFailCount++; + return; + } + + vm.prank(vaultOwner); + try vaultHub.mintShares(vaultAddress, vaultOwner, shares) { + ghost_mintCallCount++; + } catch { + ghost_mintFailCount++; + } + } + + /** + * @notice Withdraw funds from the vault + */ + function withdraw(uint96 amount) external { + uint256 withdrawable = vaultHub.withdrawableValue(vaultAddress); + if (withdrawable == 0) { + ghost_withdrawFailCount++; + return; + } + + amount = uint96(bound(amount, 1, withdrawable)); + + vm.prank(vaultOwner); + try vaultHub.withdraw(vaultAddress, vaultOwner, amount) { + _applyReport(); + ghost_withdrawCallCount++; + } catch { + ghost_withdrawFailCount++; + } + } + + /** + * @notice Rebalance vault (reduces liability) + */ + function rebalance(uint96 shares) external { + uint256 liability = vaultHub.liabilityShares(vaultAddress); + if (liability == 0) { + ghost_rebalanceFailCount++; + return; + } + + shares = uint96(bound(shares, 1, liability / 2)); + + _applyReport(); + + vm.prank(vaultOwner); + try vaultHub.rebalance(vaultAddress, shares) { + ghost_rebalanceCallCount++; + } catch { + ghost_rebalanceFailCount++; + } + } + + /** + * @notice Burn shares - reduces liability + */ + function burnShares(uint96 shares) external { + uint256 liability = vaultHub.liabilityShares(vaultAddress); + uint256 ownerShares = steth.sharesOf(vaultOwner); + + if (liability == 0 || ownerShares == 0) { + ghost_burnSharesFailCount++; + return; + } + + uint256 maxBurn = liability < ownerShares ? liability : ownerShares; + shares = uint96(bound(shares, 1, maxBurn)); + + vm.startPrank(vaultOwner); + steth.approve(address(vaultHub), type(uint256).max); + try vaultHub.transferAndBurnShares(vaultAddress, shares) { + ghost_burnSharesCallCount++; + } catch { + ghost_burnSharesFailCount++; + } + vm.stopPrank(); + } + + /** + * @notice Simulate external share rate increase (rebalancing) + * @dev This simulates the effect of Lido protocol rebalancing + */ + function simulateShareRateIncrease(uint96 ethAmount) external { + ethAmount = uint96(bound(ethAmount, 0.1 ether, 10 ether)); + + steth.simulateRebalanceRateIncrease(ethAmount); + ghost_shareRateIncreaseCallCount++; + } + + /** + * @notice Update vault connection parameters + */ + function updateConnection(uint16 newReserveRatioBP) external { + newReserveRatioBP = uint16(bound(newReserveRatioBP, 1000, 3000)); // 10% to 30% + + _applyReport(); + + // Check if vault would be healthy with new ratio + uint256 totalValue = vaultHub.totalValue(vaultAddress); + uint256 liability = vaultHub.liabilityShares(vaultAddress); + bool wouldBeHealthy = !_isThresholdBreached(totalValue, liability, newReserveRatioBP); + + if (!wouldBeHealthy) { + ghost_updateConnectionFailCount++; + return; + } + + vm.prank(address(operatorGrid)); + try + vaultHub.updateConnection( + vaultAddress, + SHARE_LIMIT, + newReserveRatioBP, + newReserveRatioBP - 200, // forcedRebalanceThreshold slightly less + INFRA_FEE_BP, + LIQUIDITY_FEE_BP, + RESERVATION_FEE_BP + ) + { + ghost_updateConnectionCallCount++; + } catch { + ghost_updateConnectionFailCount++; + } + } + + /** + * @notice Set liability shares target (redemption mechanism) + */ + function setLiabilitySharesTarget(uint96 target) external { + uint256 currentLiability = vaultHub.liabilityShares(vaultAddress); + target = uint96(bound(target, 0, currentLiability)); + + try vaultHub.setLiabilitySharesTarget(vaultAddress, target) { + ghost_setLiabilityTargetCallCount++; + } catch { + ghost_setLiabilityTargetFailCount++; + } + } + + /** + * @notice Apply vault report with current state + */ + function applyReport() external { + _applyReport(); + ghost_applyReportCallCount++; + } + + /** + * @notice Settle Lido fees + */ + function settleFees() external { + _applyReport(); + + // Check if there are unsettled fees first + VaultHub.VaultRecord memory record = vaultHub.vaultRecord(vaultAddress); + uint256 unsettledFees = record.cumulativeLidoFees - record.settledLidoFees; + + if (unsettledFees == 0) { + ghost_settleFeesFailCount++; + return; + } + + if (vaultHub.settleableLidoFeesValue(vaultAddress) == 0) { + ghost_settleFeesFailCount++; + return; + } + + try vaultHub.settleLidoFees(vaultAddress) { + ghost_settleFeesCallCount++; + } catch { + ghost_settleFeesFailCount++; + } + } + + /** + * @notice Helper to check if liability would breach threshold + */ + function _isThresholdBreached( + uint256 _vaultTotalValue, + uint256 _vaultLiabilityShares, + uint256 _thresholdBP + ) internal view returns (bool) { + uint256 liability = steth.getPooledEthBySharesRoundUp(_vaultLiabilityShares); + return liability > (_vaultTotalValue * (TOTAL_BASIS_POINTS - _thresholdBP)) / TOTAL_BASIS_POINTS; + } + + function _applyReport() internal { + lazyOracle.refreshReportTimestamp(); + uint256 timestamp = lazyOracle.getTestReportTimestamp(); + + VaultHub.VaultRecord memory record = vaultHub.vaultRecord(vaultAddress); + uint256 totalValue = vaultHub.totalValue(vaultAddress); + + uint256 activeIndex = record.inOutDelta[0].refSlot >= record.inOutDelta[1].refSlot ? 0 : 1; + int256 inOutDelta = record.inOutDelta[activeIndex].value; + + // Simulate fee accrual: add 0.01 ETH worth of fees per report (simulates protocol fees) + uint256 newCumulativeFees = record.cumulativeLidoFees + 0.01 ether; + + vm.prank(address(lazyOracle)); + vaultHub.applyVaultReport( + vaultAddress, + timestamp, + totalValue, + inOutDelta, + newCumulativeFees, + record.liabilityShares, + record.maxLiabilityShares, + 0 + ); + } +} diff --git a/test/0.8.25/vaults/vaulthub/VaultHubShortfallFuzz.t.sol b/test/0.8.25/vaults/vaulthub/VaultHubShortfallFuzz.t.sol new file mode 100644 index 0000000000..3ba59f94c9 --- /dev/null +++ b/test/0.8.25/vaults/vaulthub/VaultHubShortfallFuzz.t.sol @@ -0,0 +1,338 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity 0.8.25; + +import "forge-std/Test.sol"; +import "forge-std/console.sol"; + +import {VaultHub} from "contracts/0.8.25/vaults/VaultHub.sol"; +import {StakingVault} from "contracts/0.8.25/vaults/StakingVault.sol"; +import {OperatorGrid, TierParams} from "contracts/0.8.25/vaults/OperatorGrid.sol"; +import {IStakingVault} from "contracts/0.8.25/vaults/interfaces/IStakingVault.sol"; +import {ILidoLocator} from "contracts/common/interfaces/ILidoLocator.sol"; +import {ILido} from "contracts/common/interfaces/ILido.sol"; +import {IHashConsensus} from "contracts/common/interfaces/IHashConsensus.sol"; +import {IDepositContract} from "contracts/common/interfaces/IDepositContract.sol"; + +// OpenZeppelin contracts for proxy pattern +import {UpgradeableBeacon} from "@openzeppelin/contracts-v5.2/proxy/beacon/UpgradeableBeacon.sol"; +import {PinnedBeaconProxy} from "contracts/0.8.25/vaults/PinnedBeaconProxy.sol"; +import {OwnableUpgradeable} from "contracts/openzeppelin/5.2/upgradeable/access/OwnableUpgradeable.sol"; +import { + TransparentUpgradeableProxy +} from "@openzeppelin/contracts-v5.2/proxy/transparent/TransparentUpgradeableProxy.sol"; + +// Import test harnesses and mocks +import {LazyOracle__HarnessForVaultHub} from "./contracts/LazyOracle__HarnessForVaultHub.sol"; +import {VaultFactory} from "contracts/0.8.25/vaults/VaultFactory.sol"; +import {VaultFactoryWrapper} from "./contracts/VaultFactoryWrapper.sol"; +import {MinimalDashboard} from "./contracts/MinimalDashboard.sol"; +import { + MockStETH, + MockLidoLocator, + MockHashConsensus, + MockPredepositGuarantee, + MockDepositContract +} from "./VaultHubInvariant.t.sol"; + +/** + * @title VaultHubShortfallFuzzTest + * @notice Fuzz testing for the shortfall calculation property + * @dev This test validates that rebalancing by healthShortfallShares() makes vaults healthy + */ +contract VaultHubShortfallFuzzTest is Test { + // Constants + uint256 constant TOTAL_BASIS_POINTS = 10000; + uint256 constant CONNECT_DEPOSIT = 1 ether; + uint256 constant INITIAL_LIDO_BALANCE = 10000 ether; + + // Test parameters + uint256 constant SHARE_LIMIT = 100 ether; + uint256 constant RESERVE_RATIO_BP = 2000; // 20% + uint256 constant FORCED_REBALANCE_THRESHOLD_BP = 1800; // 18% + uint256 constant INFRA_FEE_BP = 500; + uint256 constant LIQUIDITY_FEE_BP = 400; + uint256 constant RESERVATION_FEE_BP = 100; + uint256 constant MAX_RELATIVE_SHARE_LIMIT_BP = 1000; // 10% + + // Core contracts + VaultHub public vaultHub; + OperatorGrid public operatorGrid; + StakingVault public vault; + + // Mock Part + MockStETH public steth; + MockLidoLocator public locator; + MockHashConsensus public consensus; + LazyOracle__HarnessForVaultHub public lazyOracle; + VaultFactoryWrapper public vaultFactory; + MockPredepositGuarantee public pdg; + MockDepositContract public depositContract; + UpgradeableBeacon public vaultBeacon; + + // Test addresses + address public vaultAddress; + address public vaultOwner; + address public nodeOperator; + + function setUp() public { + vaultOwner = makeAddr("vaultOwner"); + nodeOperator = makeAddr("nodeOperator"); + + vm.deal(address(this), 1000 ether); + vm.deal(vaultOwner, 1000 ether); + + // Deploy mocks + steth = new MockStETH(); + vm.deal(address(steth), INITIAL_LIDO_BALANCE); + steth.setInitialShares(INITIAL_LIDO_BALANCE); + steth.setShareRateBP(10000); // 1.0x rate + + consensus = new MockHashConsensus(); + depositContract = new MockDepositContract(); + pdg = new MockPredepositGuarantee(); + + address treasury = makeAddr("treasury"); + + // Deploy locator (temporarily without OperatorGrid and LazyOracle) + locator = new MockLidoLocator( + address(steth), + address(pdg), + address(0), // lazyOracle - will be set later + address(0), // operatorGrid - will be set later + treasury, + address(0) // accounting + ); + + // Deploy REAL LazyOracle via harness and proxy + LazyOracle__HarnessForVaultHub lazyOracleImpl = new LazyOracle__HarnessForVaultHub(address(locator)); + TransparentUpgradeableProxy lazyOracleProxy = new TransparentUpgradeableProxy( + address(lazyOracleImpl), + address(this), + "" + ); + lazyOracle = LazyOracle__HarnessForVaultHub(address(lazyOracleProxy)); + + // Initialize with sanity params + lazyOracle.initialize( + address(this), // admin + 7 days, // quarantine period + 1000, // maxRewardRatioBP (10%) + 1 ether // maxLidoFeeRatePerSecond + ); + + // Update locator with LazyOracle + locator.setLazyOracle(address(lazyOracle)); + + // Deploy OperatorGrid + OperatorGrid operatorGridImpl = new OperatorGrid(ILidoLocator(address(locator))); + TransparentUpgradeableProxy operatorGridProxy = new TransparentUpgradeableProxy( + address(operatorGridImpl), + address(this), // admin + "" // no initialization data here, call initialize separately + ); + operatorGrid = OperatorGrid(address(operatorGridProxy)); + + TierParams memory defaultTierParams = TierParams({ + shareLimit: SHARE_LIMIT, + reserveRatioBP: RESERVE_RATIO_BP, + forcedRebalanceThresholdBP: FORCED_REBALANCE_THRESHOLD_BP, + infraFeeBP: INFRA_FEE_BP, + liquidityFeeBP: LIQUIDITY_FEE_BP, + reservationFeeBP: RESERVATION_FEE_BP + }); + operatorGrid.initialize(address(this), defaultTierParams); + operatorGrid.grantRole(operatorGrid.REGISTRY_ROLE(), address(this)); + + locator.setOperatorGrid(address(operatorGrid)); + + // Deploy VaultHub + VaultHub vaultHubImpl = new VaultHub( + ILidoLocator(address(locator)), + ILido(address(steth)), + consensus, + MAX_RELATIVE_SHARE_LIMIT_BP + ); + + TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( + address(vaultHubImpl), + address(this), // admin + "" // no initialization data here, call initialize separately + ); + vaultHub = VaultHub(payable(address(proxy))); + vaultHub.initialize(address(this)); + vaultHub.grantRole(vaultHub.PAUSE_ROLE(), address(this)); + vaultHub.grantRole(vaultHub.RESUME_ROLE(), address(this)); + vaultHub.pauseFor(365 days); + vaultHub.resume(); + + locator.setVaultHub(address(vaultHub)); + + // Deploy REAL StakingVault implementation and beacon FIRST (needed for factory) + StakingVault vaultImpl = new StakingVault(address(depositContract)); + vaultBeacon = new UpgradeableBeacon(address(vaultImpl), address(this)); + + // Deploy REAL VaultFactory, then wrap it to intercept deployedVaults() calls + MinimalDashboard dashboardImpl = new MinimalDashboard(); + VaultFactory realFactory = new VaultFactory( + address(locator), // LIDO_LOCATOR + address(vaultBeacon), // BEACON + address(dashboardImpl), // DASHBOARD_IMPL + address(0) // PREVIOUS_FACTORY + ); + + // Wrap the factory to allow test vault registration + vaultFactory = new VaultFactoryWrapper(realFactory); + locator.setVaultFactory(address(vaultFactory)); + + // Deploy StakingVault + PinnedBeaconProxy vaultProxy = new PinnedBeaconProxy( + address(vaultBeacon), + abi.encodeCall(StakingVault.initialize, (address(this), nodeOperator, address(pdg))) + ); + vault = StakingVault(payable(address(vaultProxy))); + vaultAddress = address(vault); + + OwnableUpgradeable(vaultAddress).transferOwnership(vaultOwner); + vm.prank(vaultOwner); + vault.acceptOwnership(); + + // Register vault in factory (using test helper) + vaultFactory.registerTestVault(vaultAddress); + operatorGrid.registerGroup(nodeOperator, SHARE_LIMIT); + + TierParams[] memory tierParams = new TierParams[](1); + tierParams[0] = defaultTierParams; + operatorGrid.registerTiers(nodeOperator, tierParams); + + // Connect vault + vm.startPrank(vaultOwner); + vault.fund{value: CONNECT_DEPOSIT}(); + vault.pauseBeaconChainDeposits(); + vault.transferOwnership(address(vaultHub)); + vm.stopPrank(); + + vm.prank(vaultOwner); + vaultHub.connectVault(vaultAddress); + + // CREATE UNHEALTHY INITIAL STATE + console.log("\n=== Creating Unhealthy Initial State ==="); + + vm.prank(vaultOwner); + vaultHub.fund{value: 50 ether}(vaultAddress); + _applyReport(); + + uint256 maxMintable = vaultHub.totalMintingCapacityShares(vaultAddress, 0); + uint256 toMint = (maxMintable * 97) / 100; + + vm.prank(vaultOwner); + vaultHub.mintShares(vaultAddress, vaultOwner, toMint); + + steth.setShareRateBP(11500); // 1.15x - makes vault unhealthy + _applyReport(); + + bool isUnhealthy = !vaultHub.isVaultHealthy(vaultAddress); + console.log("Vault starts unhealthy:", isUnhealthy); + if (isUnhealthy) { + console.log("Shortfall:", vaultHub.healthShortfallShares(vaultAddress)); + } + } + + /** + * @notice MAIN FUZZ TEST: Shortfall calculation on initial unhealthy state + * @dev The vault STARTS unhealthy. This test verifies shortfall works across many scenarios. + * forge-config: default.fuzz.runs = 1000000 + */ + function testFuzz_shortfallOnInitialUnhealthyState(uint96 extraFunding) public { + extraFunding = uint96(bound(extraFunding, 0, 50 ether)); + + if (extraFunding > 0) { + vm.prank(vaultOwner); + vaultHub.fund{value: extraFunding}(vaultAddress); + _applyReport(); + } + + bool isHealthy = vaultHub.isVaultHealthy(vaultAddress); + if (isHealthy) return; // Extra funding made it healthy, skip + + uint256 shortfall = vaultHub.healthShortfallShares(vaultAddress); + if (shortfall == type(uint256).max || shortfall == 0) return; + + uint256 liabilityBefore = vaultHub.liabilityShares(vaultAddress); + require(liabilityBefore >= shortfall, "Shortfall exceeds liability"); + + uint256 snapshot = vm.snapshot(); + _applyReport(); + + vm.prank(vaultOwner); + vaultHub.rebalance(vaultAddress, shortfall); + + bool isHealthyAfter = vaultHub.isVaultHealthy(vaultAddress); + vm.revertTo(snapshot); + + assertTrue(isHealthyAfter, "SHORTFALL FAILED: Rebalancing by shortfall did not make vault healthy!"); + } + + /** + * @notice Fuzz test with varying share rates + * forge-config: default.fuzz.runs = 1000000 + */ + function testFuzz_shortfallWithShareRateVariation(uint16 newShareRateBP, uint96 extraFunding) public { + newShareRateBP = uint16(bound(newShareRateBP, 11000, 16000)); // 1.1x to 1.3x + extraFunding = uint96(bound(extraFunding, 0, 30 ether)); + + // Change share rate + steth.setShareRateBP(newShareRateBP); + _applyReport(); + + if (extraFunding > 0) { + vm.prank(vaultOwner); + vaultHub.fund{value: extraFunding}(vaultAddress); + _applyReport(); + } + + bool isHealthy = vaultHub.isVaultHealthy(vaultAddress); + if (isHealthy) return; + + uint256 shortfall = vaultHub.healthShortfallShares(vaultAddress); + if (shortfall == type(uint256).max || shortfall == 0) return; + + uint256 liabilityBefore = vaultHub.liabilityShares(vaultAddress); + if (liabilityBefore < shortfall) return; + + uint256 snapshot = vm.snapshot(); + _applyReport(); + + vm.prank(vaultOwner); + vaultHub.rebalance(vaultAddress, shortfall); + + bool isHealthyAfter = vaultHub.isVaultHealthy(vaultAddress); + vm.revertTo(snapshot); + + assertTrue(isHealthyAfter, "Shortfall calculation failed with share rate variation"); + } + + function _applyReport() internal { + lazyOracle.refreshReportTimestamp(); + uint256 timestamp = lazyOracle.getTestReportTimestamp(); + + VaultHub.VaultRecord memory record = vaultHub.vaultRecord(vaultAddress); + uint256 totalValue = vaultHub.totalValue(vaultAddress); + + uint256 activeIndex = record.inOutDelta[0].refSlot >= record.inOutDelta[1].refSlot ? 0 : 1; + int256 inOutDelta = record.inOutDelta[activeIndex].value; + + vm.prank(address(lazyOracle)); + vaultHub.applyVaultReport( + vaultAddress, + timestamp, + totalValue, + inOutDelta, + record.cumulativeLidoFees, + record.liabilityShares, + record.maxLiabilityShares, + 0 + ); + } +} diff --git a/test/0.8.25/vaults/vaulthub/contracts/LazyOracle__HarnessForVaultHub.sol b/test/0.8.25/vaults/vaulthub/contracts/LazyOracle__HarnessForVaultHub.sol new file mode 100644 index 0000000000..7429825cf8 --- /dev/null +++ b/test/0.8.25/vaults/vaulthub/contracts/LazyOracle__HarnessForVaultHub.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity 0.8.25; + +import {LazyOracle} from "contracts/0.8.25/vaults/LazyOracle.sol"; +import {ILidoLocator} from "contracts/common/interfaces/ILidoLocator.sol"; + +/** + * @title LazyOracle__HarnessForVaultHub + * @notice Test harness for LazyOracle that adds helper functions for VaultHub invariant testing + * @dev Extends the real LazyOracle with test-only functions to control timestamps + * + * Note: We shadow latestReportTimestamp() instead of overriding it since the base + * implementation is not marked as virtual. Tests should cast to this type to access + * the test version. + */ +contract LazyOracle__HarnessForVaultHub is LazyOracle { + // Track the last report timestamp for testing + uint256 private testReportTimestamp; + + constructor(address _lidoLocator) LazyOracle(_lidoLocator) {} + + /** + * @notice Test helper: Sets the report timestamp to current block.timestamp + * @dev This simulates a new oracle report being available + */ + function refreshReportTimestamp() external { + testReportTimestamp = block.timestamp; + } + + /** + * @notice Test helper: Manually set a specific report timestamp + * @param _timestamp The timestamp to set + */ + function setReportTimestamp(uint256 _timestamp) external { + testReportTimestamp = _timestamp; + } + + /** + * @notice Returns the test report timestamp for use in applyVaultReport + * @dev This shadows the base implementation. Call this explicitly on the harness type. + */ + function getTestReportTimestamp() external view returns (uint256) { + return testReportTimestamp > 0 ? testReportTimestamp : block.timestamp; + } +} diff --git a/test/0.8.25/vaults/vaulthub/contracts/MinimalDashboard.sol b/test/0.8.25/vaults/vaulthub/contracts/MinimalDashboard.sol new file mode 100644 index 0000000000..13ddec82a7 --- /dev/null +++ b/test/0.8.25/vaults/vaulthub/contracts/MinimalDashboard.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity 0.8.25; + +/** + * @title MinimalDashboard + * @notice Minimal Dashboard implementation for VaultFactory testing + * @dev Only exists so VaultFactory constructor doesn't revert + * VaultFactory uses Clones.cloneWithImmutableArgs which doesn't call constructor + */ +contract MinimalDashboard { + /** + * @notice Stub initialize to prevent reverts if factory tries to initialize + * @dev Dashboard initialize signature for compatibility + */ + function initialize(address, address, address, uint256, uint256) external {} + + /** + * @notice Stub grantRole for factory initialization flow + */ + function grantRole(bytes32, address) external {} + + /** + * @notice Stub revokeRole for factory initialization flow + */ + function revokeRole(bytes32, address) external {} + + /** + * @notice Stub DEFAULT_ADMIN_ROLE getter + */ + function DEFAULT_ADMIN_ROLE() external pure returns (bytes32) { + return 0x00; + } + + /** + * @notice Stub NODE_OPERATOR_MANAGER_ROLE getter + */ + function NODE_OPERATOR_MANAGER_ROLE() external pure returns (bytes32) { + return keccak256("NODE_OPERATOR_MANAGER_ROLE"); + } + + /** + * @notice Stub connectToVaultHub for factory flow + */ + function connectToVaultHub() external payable {} + + /** + * @notice Stub grantRoles for factory flow + */ + function grantRoles(RoleAssignment[] calldata) external {} + + struct RoleAssignment { + bytes32 role; + address account; + } +} diff --git a/test/0.8.25/vaults/vaulthub/contracts/VaultFactoryWrapper.sol b/test/0.8.25/vaults/vaulthub/contracts/VaultFactoryWrapper.sol new file mode 100644 index 0000000000..5501dda2d9 --- /dev/null +++ b/test/0.8.25/vaults/vaulthub/contracts/VaultFactoryWrapper.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity 0.8.25; + +import {VaultFactory} from "contracts/0.8.25/vaults/VaultFactory.sol"; +import {IVaultFactory} from "contracts/0.8.25/vaults/interfaces/IVaultFactory.sol"; + +/** + * @title VaultFactoryWrapper + * @notice Wrapper for VaultFactory that intercepts deployedVaults() calls + * @dev Uses composition (HAS-A) instead of inheritance to intercept non-virtual function + */ +contract VaultFactoryWrapper is IVaultFactory { + VaultFactory public immutable wrappedFactory; + + // Track test-registered vaults + mapping(address => bool) private testRegisteredVaults; + + constructor(VaultFactory _factory) { + wrappedFactory = _factory; + } + + /** + * @notice Test helper: Register a vault that was created outside the factory + * @param _vault The vault address to register + */ + function registerTestVault(address _vault) external { + testRegisteredVaults[_vault] = true; + } + + /** + * @notice Intercepts deployedVaults to check both real factory and test registrations + * @param _vault The vault address to check + * @return true if vault was deployed by factory or registered for testing + */ + function deployedVaults(address _vault) external view override returns (bool) { + // Check test registrations first + if (testRegisteredVaults[_vault]) { + return true; + } + + // Check real factory deployment + return wrappedFactory.deployedVaults(_vault); + } +} diff --git a/test/0.8.25/vaults/vaulthub/contracts/VaultFactory__HarnessForVaultHub.sol b/test/0.8.25/vaults/vaulthub/contracts/VaultFactory__HarnessForVaultHub.sol new file mode 100644 index 0000000000..5dc1d55af9 --- /dev/null +++ b/test/0.8.25/vaults/vaulthub/contracts/VaultFactory__HarnessForVaultHub.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity 0.8.25; + +import {VaultFactory} from "contracts/0.8.25/vaults/VaultFactory.sol"; +import {IVaultFactory} from "contracts/0.8.25/vaults/interfaces/IVaultFactory.sol"; + +/** + * @title VaultFactory__HarnessForVaultHub + * @notice Test harness for VaultFactory that adds helper functions for testing + * @dev Extends the real VaultFactory with test-only vault registration + */ +contract VaultFactory__HarnessForVaultHub is VaultFactory { + // Track test-registered vaults separately + mapping(address => bool) private testRegisteredVaults; + + constructor( + address _lidoLocator, + address _beacon, + address _dashboardImpl, + address _previousFactory + ) VaultFactory(_lidoLocator, _beacon, _dashboardImpl, _previousFactory) {} + + /** + * @notice Test helper: Register a vault that was created outside the factory + * @param _vault The vault address to register + * @dev This is needed for tests that create vaults manually via PinnedBeaconProxy + */ + function registerTestVault(address _vault) external { + testRegisteredVaults[_vault] = true; + } + + /** + * @notice Check if a vault is registered for testing + * @param _vault The vault address to check + * @return true if vault was registered via registerTestVault + */ + function isTestRegistered(address _vault) external view returns (bool) { + return testRegisteredVaults[_vault]; + } + + /** + * @notice Shadow deployedVaults to include test registrations + * @param _vault The vault address to check + * @return true if vault was deployed by factory or registered for testing + * @dev This shadows the base implementation. Cast to this type to use it. + */ + function deployedVaultsWithTest(address _vault) external view returns (bool) { + // Check test registrations first + if (testRegisteredVaults[_vault]) { + return true; + } + + // Check real factory deployment via the interface + return IVaultFactory(address(this)).deployedVaults(_vault); + } +} diff --git a/test/integration/vaults/roles.integration.ts b/test/integration/vaults/roles.integration.ts new file mode 100644 index 0000000000..3c1f9c4b2c --- /dev/null +++ b/test/integration/vaults/roles.integration.ts @@ -0,0 +1,506 @@ +import { expect } from "chai"; +import { ContractMethodArgs, ZeroAddress } from "ethers"; +import { ethers } from "hardhat"; +import { beforeEach } from "mocha"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { Dashboard } from "typechain-types"; + +import { days, ether, PDGPolicy, randomValidatorPubkey } from "lib"; +import { + autofillRoles, + createVaultWithDashboard, + getProtocolContext, + getRoleMethods, + ProtocolContext, + setupLidoForVaults, + VaultRoles, +} from "lib/protocol"; +import { vaultRoleKeys } from "lib/protocol/helpers/vaults"; + +import { Snapshot } from "test/suite"; + +type Methods = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [K in keyof T]: T[K] extends (...args: any) => any ? K : never; +}[keyof T]; + +type DashboardMethods = Methods; // "foo" | "bar" + +describe("Integration: Staking Vaults Dashboard Roles Initial Setup", () => { + let ctx: ProtocolContext; + let snapshot: string; + let originalSnapshot: string; + + let owner: HardhatEthersSigner; + let nodeOperatorManager: HardhatEthersSigner; + let stranger: HardhatEthersSigner; + + let dashboard: Dashboard; + let roles: VaultRoles; + + before(async () => { + ctx = await getProtocolContext(); + originalSnapshot = await Snapshot.take(); + + await setupLidoForVaults(ctx); + + [owner, nodeOperatorManager, stranger] = await ethers.getSigners(); + + ({ dashboard } = await createVaultWithDashboard( + ctx, + ctx.contracts.stakingVaultFactory, + owner, + nodeOperatorManager, + nodeOperatorManager, + )); + + await dashboard.connect(owner).fund({ value: ether("1") }); + await dashboard.connect(owner).setPDGPolicy(PDGPolicy.ALLOW_DEPOSIT_AND_PROVE); + }); + + beforeEach(async () => (snapshot = await Snapshot.take())); + afterEach(async () => await Snapshot.restore(snapshot)); + after(async () => await Snapshot.restore(originalSnapshot)); + + // initializing contracts without signers + describe("No roles are assigned", () => { + it("Verify that roles are not assigned", async () => { + const roleMethods = getRoleMethods(dashboard); + + for (const role of vaultRoleKeys) { + expect(await dashboard.getRoleMembers(await roleMethods[role])).to.deep.equal([], `Role "${role}" is assigned`); + } + }); + + describe.skip("Verify ACL for methods that require only role", () => { + describe("Dashboard methods", () => { + it("setNodeOperatorFeeRecipient", async () => { + await testGrantingRole( + "setFeeRecipient", + await dashboard.NODE_OPERATOR_MANAGER_ROLE(), + [stranger], + nodeOperatorManager, + ); + }); + }); + }); + }); + + // initializing contracts without signers + describe("No roles are assigned", () => { + it("Verify that roles are not assigned", async () => { + const roleMethods = getRoleMethods(dashboard); + + for (const role of vaultRoleKeys) { + expect(await dashboard.getRoleMembers(await roleMethods[role])).to.deep.equal([], `Role "${role}" is assigned`); + } + }); + + describe.skip("Verify ACL for methods that require only role", () => { + describe("Dashboard methods", () => { + it("setNodeOperatorFeeRecipient", async () => { + await testGrantingRole( + "setFeeRecipient", + await dashboard.NODE_OPERATOR_MANAGER_ROLE(), + [stranger], + nodeOperatorManager, + ); + }); + }); + }); + }); + + // initializing contracts with signers + describe("All the roles are assigned", () => { + before(async () => { + roles = await autofillRoles(dashboard, nodeOperatorManager); + }); + + it("Allows anyone to read public metrics of the vault", async () => { + expect(await dashboard.connect(stranger).accruedFee()).to.equal(0); + expect(await dashboard.connect(stranger).withdrawableValue()).to.equal(ether("1")); + }); + + it("Allows to retrieve roles addresses", async () => { + expect(await dashboard.getRoleMembers(await dashboard.MINT_ROLE())).to.deep.equal([roles.minter.address]); + }); + + it("Allows NO Manager to add and remove new managers", async () => { + await dashboard.connect(nodeOperatorManager).grantRole(await dashboard.NODE_OPERATOR_MANAGER_ROLE(), stranger); + expect(await dashboard.getRoleMembers(await dashboard.NODE_OPERATOR_MANAGER_ROLE())).to.deep.equal([ + nodeOperatorManager.address, + stranger.address, + ]); + await dashboard.connect(nodeOperatorManager).revokeRole(await dashboard.NODE_OPERATOR_MANAGER_ROLE(), stranger); + expect(await dashboard.getRoleMembers(await dashboard.NODE_OPERATOR_MANAGER_ROLE())).to.deep.equal([ + nodeOperatorManager.address, + ]); + }); + + describe("Verify ACL for methods that require only role", () => { + describe("Dashboard methods", () => { + it("recoverERC20", async () => { + await testMethod( + "recoverERC20", + { + successUsers: [owner], + failingUsers: Object.values(roles).filter((r) => r !== owner), + }, + [ZeroAddress, owner, 1n], + await dashboard.DEFAULT_ADMIN_ROLE(), + ); + }); + + it("collectERC20FromVault", async () => { + await testMethod( + "collectERC20FromVault", + { + successUsers: [roles.assetCollector, owner], + failingUsers: Object.values(roles).filter((r) => r !== owner && r !== roles.assetCollector), + }, + [ZeroAddress, owner, 1n], + await dashboard.COLLECT_VAULT_ERC20_ROLE(), + ); + }); + + it("triggerValidatorWithdrawal", async () => { + await testMethod( + "triggerValidatorWithdrawals", + { + successUsers: [roles.validatorWithdrawalTriggerer, owner], + failingUsers: Object.values(roles).filter((r) => r !== roles.validatorWithdrawalTriggerer && r !== owner), + }, + ["0x", [0n], stranger], + await dashboard.TRIGGER_VALIDATOR_WITHDRAWAL_ROLE(), + ); + }); + + it("requestValidatorExit", async () => { + await testMethod( + "requestValidatorExit", + { + successUsers: [roles.validatorExitRequester, owner], + failingUsers: Object.values(roles).filter((r) => r !== roles.validatorExitRequester && r !== owner), + }, + ["0x" + "ab".repeat(48)], + await dashboard.REQUEST_VALIDATOR_EXIT_ROLE(), + ); + }); + + it("resumeBeaconChainDeposits", async () => { + await testMethod( + "resumeBeaconChainDeposits", + { + successUsers: [roles.depositResumer, owner], + failingUsers: Object.values(roles).filter((r) => r !== roles.depositResumer && r !== owner), + }, + [], + await dashboard.RESUME_BEACON_CHAIN_DEPOSITS_ROLE(), + ); + }); + + it("pauseBeaconChainDeposits", async () => { + await testMethod( + "pauseBeaconChainDeposits", + { + successUsers: [roles.depositPauser, owner], + failingUsers: Object.values(roles).filter((r) => r !== roles.depositPauser && r !== owner), + }, + [], + await dashboard.PAUSE_BEACON_CHAIN_DEPOSITS_ROLE(), + ); + }); + + it("unguaranteedDepositToBeaconChain", async () => { + await testMethod( + "unguaranteedDepositToBeaconChain", + { + successUsers: [roles.unguaranteedDepositor, nodeOperatorManager], + failingUsers: Object.values(roles).filter( + (r) => r !== roles.unguaranteedDepositor && r !== nodeOperatorManager, + ), + }, + [ + [ + { + pubkey: randomValidatorPubkey(), + amount: ether("1"), + signature: new Uint8Array(32), + depositDataRoot: new Uint8Array(32), + }, + ], + ], + await dashboard.NODE_OPERATOR_UNGUARANTEED_DEPOSIT_ROLE(), + ); + }); + + it("proveUnknownValidatorsToPDG", async () => { + await testMethod( + "proveUnknownValidatorsToPDG", + { + successUsers: [roles.unknownValidatorProver, nodeOperatorManager], + failingUsers: Object.values(roles).filter( + (r) => r !== roles.unknownValidatorProver && r !== nodeOperatorManager, + ), + }, + [ + [ + { + proof: ["0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"], + pubkey: "0x", + validatorIndex: 0n, + childBlockTimestamp: 0n, + slot: 0n, + proposerIndex: 0n, + }, + ], + ], + await dashboard.NODE_OPERATOR_PROVE_UNKNOWN_VALIDATOR_ROLE(), + ); + }); + + // requires prepared state for this test to pass, skipping for now + it("addFeeExemption", async () => { + await testMethod( + "addFeeExemption", + { + successUsers: [roles.nodeOperatorFeeExemptor, nodeOperatorManager], + failingUsers: Object.values(roles).filter( + (r) => r !== roles.nodeOperatorFeeExemptor && r !== nodeOperatorManager, + ), + }, + [100n], + await dashboard.NODE_OPERATOR_FEE_EXEMPT_ROLE(), + ); + }); + + it("rebalanceVaultWithShares", async () => { + await testMethod( + "rebalanceVaultWithShares", + { + successUsers: [roles.rebalancer, owner], + failingUsers: Object.values(roles).filter((r) => r !== roles.rebalancer && r !== owner), + }, + [1n], + await dashboard.REBALANCE_ROLE(), + ); + }); + + it("rebalanceVaultWithEther", async () => { + await testMethod( + "rebalanceVaultWithEther", + { + successUsers: [roles.rebalancer, owner], + failingUsers: Object.values(roles).filter((r) => r !== roles.rebalancer && r !== owner), + }, + [1n], + await dashboard.REBALANCE_ROLE(), + ); + }); + + it("mintWstETH", async () => { + await testMethod( + "mintWstETH", + { + successUsers: [roles.minter, owner], + failingUsers: Object.values(roles).filter((r) => r !== roles.minter && r !== owner), + }, + [ZeroAddress, 0, stranger], + await dashboard.MINT_ROLE(), + ); + }); + + it("mintStETH", async () => { + await testMethod( + "mintStETH", + { + successUsers: [roles.minter, owner], + failingUsers: Object.values(roles).filter((r) => r !== roles.minter && r !== owner), + }, + [stranger, 1n], + await dashboard.MINT_ROLE(), + ); + }); + + it("mintShares", async () => { + await testMethod( + "mintShares", + { + successUsers: [roles.minter, owner], + failingUsers: Object.values(roles).filter((r) => r !== roles.minter && r !== owner), + }, + [stranger, 100n], + await dashboard.MINT_ROLE(), + ); + }); + + // requires prepared state for this test to pass, skipping for now + // fund 2 ether, cause vault has 1 ether locked already + it("withdraw", async () => { + await dashboard.connect(roles.funder).fund({ value: ether("2") }); + await testMethod( + "withdraw", + { + successUsers: [roles.withdrawer, owner], + failingUsers: Object.values(roles).filter((r) => r !== roles.withdrawer && r !== owner), + }, + [stranger, ether("1")], + await dashboard.WITHDRAW_ROLE(), + ); + }); + + it("fund", async () => { + await testMethod( + "fund", + { + successUsers: [roles.funder, owner], + failingUsers: Object.values(roles).filter((r) => r !== roles.funder && r !== owner), + }, + [{ value: 1n }], + await dashboard.FUND_ROLE(), + ); + }); + + //TODO: burnWstETH, burnStETH, burnShares + + it("voluntaryDisconnect", async () => { + await testMethod( + "voluntaryDisconnect", + { + successUsers: [roles.disconnecter, owner], + failingUsers: Object.values(roles).filter((r) => r !== roles.disconnecter && r !== owner), + }, + [], + await dashboard.VOLUNTARY_DISCONNECT_ROLE(), + ); + }); + + it("requestTierChange", async () => { + await testMethod( + "changeTier", + { + successUsers: [roles.tierChanger, owner], + failingUsers: Object.values(roles).filter((r) => r !== roles.tierChanger && r !== owner), + }, + [1n, 1n], + await dashboard.VAULT_CONFIGURATION_ROLE(), + ); + }); + + describe("renounceRole()", () => { + for (const role of vaultRoleKeys) { + it(`reverts if called for role ${role}`, async () => { + const roleMethods = getRoleMethods(dashboard); + const roleId = await roleMethods[role]; + const caller = roles[role]; + await expect(dashboard.connect(caller).renounceRole(roleId, caller)).to.be.revertedWithCustomError( + dashboard, + "RoleRenouncementDisabled", + ); + }); + } + }); + }); + }); + + describe("Verify ACL for methods that require confirmations", () => { + it("setNodeOperatorFeeBP", async () => { + await expect(dashboard.connect(owner).setFeeRate(1n)).not.to.emit(dashboard, "FeeRateSet"); + await expect(dashboard.connect(nodeOperatorManager).setFeeRate(1n)).to.emit(dashboard, "FeeRateSet"); + + await testMethodConfirmedRoles( + "setFeeRate", + { + successUsers: [], + failingUsers: Object.values(roles).filter((r) => r !== owner && r !== nodeOperatorManager), + }, + [1n], + ); + }); + + it("setConfirmExpiry", async () => { + await expect(dashboard.connect(owner).setConfirmExpiry(days(7n))).not.to.emit(dashboard, "ConfirmExpirySet"); + await expect(dashboard.connect(nodeOperatorManager).setConfirmExpiry(days(7n))).to.emit( + dashboard, + "ConfirmExpirySet", + ); + + await testMethodConfirmedRoles( + "setConfirmExpiry", + { + successUsers: [], + failingUsers: Object.values(roles).filter((r) => r !== owner && r !== nodeOperatorManager), + }, + [days(7n)], + ); + }); + }); + + it("Allows anyone to read public metrics of the vault", async () => { + expect(await dashboard.connect(stranger).accruedFee()).to.equal(0); + expect(await dashboard.connect(stranger).withdrawableValue()).to.equal(ether("1")); + }); + + it("Allows to retrieve roles addresses", async () => { + expect(await dashboard.getRoleMembers(await dashboard.MINT_ROLE())).to.deep.equal([roles.minter.address]); + }); + }); + + async function testMethod( + methodName: DashboardMethods, + { successUsers, failingUsers }: { successUsers: HardhatEthersSigner[]; failingUsers: HardhatEthersSigner[] }, + argument: T, + requiredRole: string, + ) { + for (const user of failingUsers) { + await expect(dashboard.connect(user)[methodName](...(argument as ContractMethodArgs))) + .to.be.revertedWithCustomError(dashboard, "AccessControlUnauthorizedAccount") + .withArgs(user, requiredRole); + } + + for (const user of successUsers) { + await expect( + dashboard.connect(user)[methodName](...(argument as ContractMethodArgs)), + ).to.be.not.revertedWithCustomError(dashboard, "AccessControlUnauthorizedAccount"); + } + } + + async function testMethodConfirmedRoles( + methodName: DashboardMethods, + { successUsers, failingUsers }: { successUsers: HardhatEthersSigner[]; failingUsers: HardhatEthersSigner[] }, + argument: T, + ) { + for (const user of failingUsers) { + await expect( + dashboard.connect(user)[methodName](...(argument as ContractMethodArgs)), + ).to.be.revertedWithCustomError(dashboard, "SenderNotMember"); + } + + for (const user of successUsers) { + await expect( + dashboard.connect(user)[methodName](...(argument as ContractMethodArgs)), + ).to.be.not.revertedWithCustomError(dashboard, "SenderNotMember"); + } + } + + async function testGrantingRole( + methodName: DashboardMethods, + roleToGrant: string, + argument: T, + roleGratingActor: HardhatEthersSigner, + ) { + await expect( + dashboard.connect(stranger)[methodName](...(argument as ContractMethodArgs)), + ).to.be.revertedWithCustomError(dashboard, "AccessControlUnauthorizedAccount"); + + await dashboard.connect(roleGratingActor).grantRole(roleToGrant, stranger); + + await expect( + dashboard.connect(stranger)[methodName](...(argument as ContractMethodArgs)), + ).to.not.be.revertedWithCustomError(dashboard, "AccessControlUnauthorizedAccount"); + + await dashboard.connect(roleGratingActor).revokeRole(roleToGrant, stranger); + } +});