diff --git a/.github/workflows/certora-fastRules.yml b/.github/workflows/certora-fastRules.yml new file mode 100644 index 000000000..d6a6b5795 --- /dev/null +++ b/.github/workflows/certora-fastRules.yml @@ -0,0 +1,92 @@ +name: Certora Prover Fast Rules only + +env: + CONFIGS: | + certora/conf/libs/LibBit.conf + certora/conf/libs/LiquidationLogic_Bonus.conf + certora/conf/libs/LiquidationLogic_debtToLiquidate.conf + certora/conf/libs/LiquidationLogic.conf --exclude_rule collateralToLiquidateValueLessThanDebtToLiquidate collateralToLiquidateValueLessThanDebtToLiquidate_general + certora/conf/libs/Math.conf + certora/conf/libs/Premium.conf + certora/conf/libs/SharesMath.conf + certora/conf/libs/SpokeUtils_toValue.conf + certora/conf/libs/VerifySymbolicPositionStatus.conf + certora/conf/libs/PositionStatus.conf + certora/conf/Hub.conf --exclude_rule noChangeToOtherSpoke supplyExchangeRateIsMonotonic supplyExchangeRateIsMonotonic_eliminateDeficit_simplified + certora/conf/Hub.conf --rule noChangeToOtherSpoke + certora/conf/HubAccrueIntegrity.conf + certora/conf/HubAccrueUnrealizedFee.conf + certora/conf/HubAccrueSupplyRate.conf --exclude_rule previewRemoveByShares_withoutAccrue_time_monotonic previewRemoveByAssets_withoutAccrue_time_monotonic + certora/conf/HubIntegrity.conf + certora/conf/HubValidState.conf --exclude_rule totalAssetsVsShares_eliminateDeficit totalAssetsVsShares + certora/conf/HubValidState_totalAssets.conf + certora/conf/Liquidation.conf + certora/conf/LiquidationIntegrity.conf + certora/conf/LiquidationUserIntegrity.conf + certora/conf/Spoke.conf --rule drawnSharesZero + certora/conf/Spoke.conf --exclude_rule drawnSharesZero noCollateralNoDebt increaseCollateralOrReduceDebtFunctions + certora/conf/Spoke.conf --rule increaseCollateralOrReduceDebtFunctions + certora/conf/SpokeHealthCheck.conf + certora/conf/SpokeHealthFactor.conf --exclude_rule userHealthAboveThreshold userHealthBelowThresholdCanOnlyIncreaseHealthFactor + certora/conf/SpokeIntegrity.conf + certora/conf/SpokeUserIntegrity.conf + certora/conf/SpokeWithHub.conf --rule userDrawnShareConsistency + certora/conf/SpokeWithHub.conf --rule userPremiumShareConsistency + certora/conf/SpokeWithHub.conf --rule userPremiumOffsetConsistency + certora/conf/SpokeWithHub.conf --rule userSuppliedShareConsistency + certora/conf/SpokeWithHub.conf --exclude_rule userDrawnShareConsistency userPremiumShareConsistency userPremiumOffsetConsistency userSuppliedShareConsistency + ######### ######### + ######### rules that are too long/flaky to run in ci, run manually ######### + ######### ######### + # certora/conf/Spoke_noCollateralNoDebt.conf + # certora/conf/Hub.conf --rule supplyExchangeRateIsMonotonic + # certora/conf/Hub.conf --rule supplyExchangeRateIsMonotonic_eliminateDeficit_simplified + # certora/conf/HubAdditivity.conf --rule drawAdditivity + # certora/conf/HubAdditivity.conf --rule restoreAdditivity + # certora/conf/HubAdditivity.conf --rule reportDeficitAdditivity + # certora/conf/HubAdditivity.conf --exclude_rule drawAdditivity restoreAdditivity reportDeficitAdditivity + # certora/conf/SpokeHealthFactor.conf --rule userHealthAboveThreshold + # certora/conf/SpokeHealthFactor.conf --rule userHealthBelowThresholdCanOnlyIncreaseHealthFactor + # certora/conf/HubAccrueSupplyRate.conf --rule previewRemoveByShares_withoutAccrue_time_monotonic previewRemoveByAssets_withoutAccrue_time_monotonic + # certora/conf/libs/LiquidationLogic.conf --rule collateralToLiquidateValueLessThanDebtToLiquidate + # certora/conf/HubValidState.conf --rule totalAssetsVsShares_eliminateDeficit + # certora/conf/LiquidationReportDeficit.conf + +on: + pull_request: + branches: + - main + - certora + workflow_dispatch: + +jobs: + verify: + 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 + + # (Optional) Add installation steps for your project + - name: Setup Node.js + uses: actions/setup-node@v4 + - name: Install dependencies + run: npm install + + # Run Certora Prover + - uses: Certora/certora-run-action@v2 + with: + # Add your configurations as lines, each line is separated. + # Specify additional options for each configuration by adding them after the configuration. + configurations: ${{ env.CONFIGS }} + solc-versions: 0.8.28 + job-name: "Verified Rules" + certora-key: ${{ secrets.CERTORAKEY }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index c92814bc4..6039d2442 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ report/ .DS_Store .venv/ +.certora_internal/ diff --git a/certora/README.md b/certora/README.md new file mode 100644 index 000000000..4af9cf5cb --- /dev/null +++ b/certora/README.md @@ -0,0 +1,633 @@ +# Certora Formal Verification + +This folder contains the formal verification specifications for the Aave V4 protocol using the Certora Prover. + +## Folder Structure + +``` +certora/ +├── conf/ # Configuration files for running the prover +│ ├── libs/ # Library-specific configurations +│ └── *.conf # Main configuration files +├── harness/ # Solidity harness contracts for verification +│ ├── HubHarness.sol # Hub contract harness exposing internal functions +│ ├── LibBitHarness.sol # LibBit library harness +│ ├── LiquidationLogicHarness.sol # LiquidationLogic library harness +│ ├── MathWrapper.sol # Math library wrapper for verification +│ └── PremiumWrapper.sol # Premium library wrapper for verification +├── spec/ # CVL specification files +│ ├── libs/ # Library specifications +│ └── symbolicRepresentation/ # Symbolic representations for CVL +├── runAll.sh # Script to run all conf files +└── compileAll.sh # Script to compile all contracts +``` + +## Key Properties Verified + +The formal verification focuses on the following critical safety properties: + +### Solvency & Share Rate + +- **Share Rate Monotonicity** - The exchange rate between shares and assets never decreases, protecting LP token holders +- **Total Assets ≥ Total Shares** - Ensures the protocol remains solvent +- **External Solvency** - Hub underlying balance always covers total added assets + +### Position Safety + +- **No Collateral → No Debt** - Users without collateral cannot accumulate debt +- **Borrowing Flag Consistency** - Borrowing status accurately reflects drawn shares +- **Health Factor Maintenance** - User health stays above liquidation threshold after operations +- **Premium Debt Consistency** - Premium shares and offset maintain consistent relationship with drawn shares + +### State Consistency + +- **Spoke Isolation** - Operations on one spoke don't affect other spokes +- **Sum Invariants** - Sum of spoke supplies/drawn shares equals totals +- **Reserve ID Validity** - Reserve mappings remain consistent +- **Dynamic Config Consistency** - User dynamic config keys are consistent with reserve config + +### Accrue Integrity + +- **Idempotency** - Calling accrue twice is equivalent to calling once +- **Index Monotonicity** - Interest indices only increase + +### Liquidation Safety + +- **Healthy Accounts Protected** - Accounts with health factor above threshold cannot be liquidated +- **Debt Monotonicity** - Liquidation always reduces debt +- **Collateral Bounds** - Collateral seized does not exceed user's total collateral +- **Liquidation Bonus Bounds** - Bonus is bounded between no bonus and max bonus + +## Prerequisites + +1. Install the Certora Prover CLI: + + ```bash + pip install certora-cli + ``` + +2. Set your Certora API key: + ```bash + export CERTORAKEY= + ``` + +## Running the Prover + +### Run a Single Configuration + +```bash +certoraRun certora/conf/.conf +``` + +### Run a Specific Rule + +```bash +certoraRun certora/conf/.conf --rule --msg "" +``` + +## Continuous Integration (CI) + +The CI workflow (`.github/workflows/certora-fastRules.yml`) automatically runs Certora verification on pull requests. **The CI only runs rules that are fast** to keep the workflow execution time reasonable. Rules that are too long or flaky are excluded from CI and should be run manually. + +### CI Configuration + +- **Workflow File:** `.github/workflows/certora-fastRules.yml` +- **Trigger:** Pull requests to `main` and `certora` branches, or manual workflow dispatch +- **Scope:** Only fast-running rules are included in CI +- **Excluded Rules:** Long-running or flaky rules are commented out in the workflow file and should be run manually + +### Running Excluded Rules Manually + +Rules that are excluded from CI (marked as "too long/flaky to run in ci, run manually" in the workflow file) should be run locally or on-demand. These include: + +- `supplyExchangeRateIsMonotonic` (Hub.conf) +- `noChangeToOtherSpoke` (Hub.conf) +- `drawAdditivity`, `restoreAdditivity`, `reportDeficitAdditivity` (HubAdditivity.conf) +- `previewRemoveByShares_withoutAccrue_time_monotonic`, `previewAddByShares_withoutAccrue_time_monotonic` (HubAccrueSupplyRate.conf) + +### Documentation + +For more information on the Certora Prover and CVL specification language, see: + +- [Certora Documentation](https://docs.certora.com/) +- [CVL Language Reference](https://docs.certora.com/en/latest/docs/cvl/index.html) +- [Certora Prover CLI](https://docs.certora.com/en/latest/docs/prover/cli/index.html) + +--- + +## Hub Specifications + +### `HubBase.spec` + +**Base definitions for Hub specifications.** + +- **Imports:** `ERC20s_CVL.spec`, `Math_CVL.spec`, `common.spec` +- **Purpose:** Contains safe assumptions and summarizations used across all Hub spec files +- **Key Summaries:** + - `calculateInterestRate` → NONDET + - `Premium.calculatePremiumRay` → CVL implementation + +### `Hub.spec` + +**Main Hub verification rules.** + +- **Config:** `certora/conf/Hub.conf` +- **Imports:** `ERC20s_CVL.spec`, `Math_CVL.spec`, `HubValidState.spec` +- **Purpose:** State change rules where validation functions are ignored, assuming `accrue` has been called +- **Key Summaries:** All `_validate*` functions → NONDET +- **Key Rules:** + - `supplyExchangeRateIsMonotonic` - Share exchange rate never decreases (critical for LP token safety) + - `noChangeToOtherSpoke` - Operations on one spoke don't affect other spokes' state + - `totalAssetsCompareToSuppliedAmount` - Total assets always >= total shares (solvency) + - `accrueWasCalled` - Ensures accrue is called before state-changing operations + +### `HubValidState.spec` + +**Hub valid state properties and invariants.** + +- **Config:** `certora/conf/HubValidState.conf`, `certora/conf/HubValidState_totalAssets.conf` +- **Imports:** `ERC20s_CVL.spec`, `Math_CVL.spec`, `HubBase.spec` +- **Purpose:** Verifies invariants about the Hub's state, assuming a given drawnIndex and accrue was called +- **Key Features:** + - Ghost variables for tracking spoke supply, drawn amounts, and premium offsets + - Hooks on storage operations to maintain ghost consistency + - Sum invariants for spoke data +- **Key Rules/Invariants:** + - `solvency_external` - Hub underlying balance >= total added assets (external solvency) + - `totalAssetsVsShares` - Total assets always >= total shares (share rate >= 1) + - `sumOfSpokeSupply` - Sum of all spoke supplies equals total supply + - `sumOfSpokeDrawnShares` - Sum of all spoke drawn shares equals total drawn + - `premiumOffset_Integrity` - Premium offset tracking consistency +- **Additional Config:** `HubValidState_totalAssets.conf` runs `totalAssetsVsShares` with parallel splitting + +### `HubIntegrity.spec` + +**Hub integrity verification rules.** + +- **Config:** `certora/conf/HubIntegrity.conf` +- **Imports:** `ERC20s_CVL.spec`, `Math_CVL.spec`, `HubValidState.spec` +- **Purpose:** Verifies that state changes are consistent (e.g., add increases balances, remove decreases them) +- **Key Rules:** + - `nothingForZero_add` - Add operation increases balances + - `nothingForZero_remove` - Remove operation decreases balances + +### `HubAccrueIntegrity.spec` + +**Accrue function integrity proofs.** + +- **Config:** `certora/conf/HubAccrueIntegrity.conf` +- **Imports:** `HubBase.spec` +- **Purpose:** Unit test properties of `AssetLogic.accrue()` function +- **Key Rules:** + - `runningTwiceIsEquivalentToOne` - Idempotency of accrue + - Index monotonicity rules + - Interest rate calculation rules + +### `HubAccrueSupplyRate.spec` + +**Supply rate verification.** + +- **Config:** `certora/conf/HubAccrueSupplyRate.conf` +- **Purpose:** Verifies supply rate calculations +- **Split Rules:** When running manually, use `--split_rules` for: `previewAddByShares_withoutAccrue_time_monotonic`, `previewRemoveByShares_withoutAccrue_time_monotonic`, `previewRemoveByAssets_withoutAccrue_time_monotonic` + +### `HubAccrueUnrealizedFee.spec` + +**Unrealized fee verification.** + +- **Config:** `certora/conf/HubAccrueUnrealizedFee.conf` +- **Purpose:** Verifies unrealized fee calculations + +### `HubAdditivity.spec` + +**Additivity properties of Hub operations.** + +- **Config:** `certora/conf/HubAdditivity.conf` +- **Imports:** `ERC20s_CVL.spec`, `Math_CVL.spec`, `SharesMath.spec` +- **Purpose:** Verifies that splitting operations is less beneficial than single operations +- **Key Rules:** Additivity proofs for `add`, `remove`, `draw`, `restore`, `reportDeficit`, `eliminateDeficit` + +--- + +## Spoke Specifications + +### `SpokeBase.spec` + +**Base definitions for Spoke specifications.** + +- **Imports:** `SpokeBaseSummaries.spec` +- **Purpose:** Safe assumptions and summarizations for all Spoke spec files +- **Key Features:** + - `setup()` function with common requirements + - `outOfScopeFunctions` definition for filtering + - `increaseCollateralOrReduceDebtFunctions` definition + - Paused/frozen flag ghost variables + +### `SpokeBaseSummaries.spec` + +**Method summaries for Spoke specifications.** + +- **Imports:** `common.spec`, `SymbolicPositionStatus.spec` +- **Purpose:** Contains method summaries shared across Spoke specs +- **Key Summaries:** + - Sorting functions → CVL implementation + - Price functions → Symbolic representation + - Authority checks → NONDET + +### `Spoke.spec` + +**Main Spoke verification rules and invariants.** + +- **Config:** `certora/conf/Spoke.conf`, `certora/conf/Spoke_noCollateralNoDebt.conf` +- **Imports:** `SpokeBase.spec`, `SymbolicPositionStatus.spec`, `SymbolicHub.spec` +- **Purpose:** Spoke-independent verification (no link to Hub) +- **Key Features:** + - Symbolic Hub summaries for external calls + - Index tracking per asset per block + - User position invariants + - userGhost tracking for single-user operations +- **Key Rules:** + - `increaseCollateralOrReduceDebtFunctions` - Functions either increase collateral or reduce debt + - `paused_noChange` - No state changes when paused + - `frozen_onlyReduceDebtAndCollateral` - Frozen reserves only allow debt/collateral reduction + - `updateUserRiskPremium_preservesPremiumDebt` - Risk premium updates preserve premium debt + - `noCollateralNoDebt` - User with no collateral cannot have debt (critical safety property) + - `collateralFactorNotZero` - Borrowed reserves must have non-zero collateral factor + - `deterministicUserDebtValue` - User debt calculation is deterministic +- **Key Invariants:** + - `isBorrowingIFFdrawnShares` - Borrowing flag set iff user has drawn shares + - `drawnSharesZero` - Zero drawn shares implies zero premium shares + - `validReserveId` - Reserve ID validity and consistency + - `validReserveId_single` - Single reserve ID validity + - `validReserveId_singleUser` - Reserve ID validity for single user + - `uniqueAssetIdPerReserveId` - Each reserve maps to unique asset + - `realizedPremiumRayConsistency` - Premium offset <= premium shares \* drawn index + - `drawnSharesRiskEQPremiumShares` - Drawn shares \* risk premium == premium shares + - `dynamicConfigKeyConsistency` - User config key <= reserve config key +- **Additional Config:** `Spoke_noCollateralNoDebt.conf` runs `noCollateralNoDebt` with parallel splitting prover args + +### `SpokeIntegrity.spec` + +**Spoke operation integrity rules.** + +- **Config:** `certora/conf/SpokeIntegrity.conf` +- **Imports:** `SpokeBase.spec`, `SymbolicPositionStatus.spec`, `SymbolicHub.spec` +- **Purpose:** Verifies integrity of individual Spoke operations +- **Key Rules:** + - `nothingForZero_supply` - Supply with zero amount has no effect + - `nothingForZero_withdraw` - Withdraw with zero amount has no effect + - `nothingForZero_borrow` - Borrow with zero amount has no effect + - `nothingForZero_repay` - Repay with zero amount has no effect + - `supply_noChangeToOther` - Supply doesn't affect other users + - `withdraw_noChangeToOther` - Withdraw doesn't affect other users + - `borrow_noChangeToOther` - Borrow doesn't affect other users + - `repay_noChangeToOther` - Repay doesn't affect other users + - `onlyPositionManagerCanChange` - Only position manager can modify positions + +### `SpokeHealthCheck.spec` + +**Health factor verification.** + +- **Config:** `certora/conf/SpokeHealthCheck.conf` +- **Imports:** `SpokeBase.spec`, `SymbolicHub.spec` +- **Purpose:** Verifies that health factor is checked after position updates +- **Key Rules:** + - `userHealthStaysAboveThreshold` - Health factor maintained after operations + +### `SpokeHealthFactor.spec` + +**Advanced health factor verification with ghost tracking.** + +- **Config:** `certora/conf/SpokeHealthFactor.conf` +- **Imports:** `SpokeBaseSummaries.spec`, `SymbolicPositionStatus.spec` +- **Purpose:** Verifies health factor using ghost variables for collateral and debt tracking +- **Key Features:** + - Ghost variables for total collateral and debt values + - Hooks on position storage to track value changes + - Symbolic price functions +- **Key Rules:** + - `userHealthAboveThreshold` - Health factor stays above liquidation threshold + +### `SpokeUserIntegrity.spec` + +**User position integrity.** + +- **Config:** `certora/conf/SpokeUserIntegrity.conf` +- **Purpose:** Verifies that only one user's account is updated at a time + +### `SpokeHubIntegrity.spec` + +**Spoke-Hub integration verification.** + +- **Config:** `certora/conf/SpokeWithHub.conf` +- **Imports:** `SpokeBase.spec`, `SymbolicPositionStatus.spec`, `HubValidState.spec` +- **Purpose:** Verifies consistency between Spoke user positions and Hub spoke data +- **Key Invariants:** + - `userDrawnShareConsistency` - User drawn shares match Hub records + - `userSuppliedShareConsistency` - User supplied shares match Hub records + - `userPremiumShareConsistency` - Premium shares consistency + - `userPremiumOffsetConsistency` - Premium offset consistency + - `underlyingAssetConsistency` - Underlying asset matches Hub asset + +--- + +## Liquidation Specifications + +### `Liquidation.spec` + +**Liquidation operation verification.** + +- **Config:** `certora/conf/Liquidation.conf` +- **Imports:** `SpokeBase.spec`, `SymbolicPositionStatus.spec`, `SymbolicHub.spec` +- **Purpose:** Verifies safety properties of liquidation operations +- **Key Rules:** + - `sanityCheck` - Basic sanity check for liquidation + - `borrowingFlagSetIFFdrawnShares_liquidationCall` - Borrowing flag consistency after liquidation + - `healthyAccountCannotBeLiquidated` - Accounts above health threshold cannot be liquidated + - `paused_noLiquidation` - No liquidation when paused + - `monotonicityOfDebtDecrease_collateralIncrease` - Liquidation always decreases debt + - `moreThanOneCollateral_noReportDeficit` - No deficit reported when multiple collaterals exist + - `noChangeToOtherAccounts_liquidationCall` - Liquidation doesn't affect uninvolved accounts + +### `LiquidationUserIntegrity.spec` + +**Liquidation user isolation verification.** + +- **Config:** `certora/conf/LiquidationUserIntegrity.conf` +- **Imports:** `SpokeBase.spec`, `SymbolicPositionStatus.spec`, `SymbolicHub.spec` +- **Purpose:** Verifies that liquidation only affects the liquidated user +- **Key Rules:** + - `onlyOneUserDebtChanges_liquidationCall` - Only liquidated user's debt changes + +--- + +## Library Specifications + +### `libs/Math.spec` + +**Mathematical function verification.** + +- **Config:** `certora/conf/libs/Math.conf` +- **Purpose:** Proves CVL representations match Solidity implementations +- **Verified Functions:** + - `mulDivDown`, `mulDivUp` + - `rayMulDown`, `rayMulUp`, `rayDivDown`, `rayDivUp` + - `wadDivDown`, `wadDivUp` + - `percentMulDown`, `percentMulUp` + - `fromRayUp`, `toRay` + +### `libs/SharesMath.spec` + +**Shares math library verification.** + +- **Config:** `certora/conf/libs/SharesMath.conf` +- **Purpose:** Proves mathematical properties of share calculations +- **Key Rules:** + - Monotonicity of `toSharesUp`, `toSharesDown`, `toAssetsUp`, `toAssetsDown` + - Additivity properties + - Inverse relationships + +### `libs/LiquidationLogic.spec` + +**Liquidation amounts calculation verification.** + +- **Config:** `certora/conf/libs/LiquidationLogic.conf` +- **Harness:** `LiquidationLogicHarness.sol` +- **Purpose:** Verifies `_calculateLiquidationAmounts` function properties +- **Key Rules:** + - `sanityCheck` - Basic sanity check + - `debtToLiquidateNotExceedBalance` - Debt to liquidate bounded by reserve balance + - `debtToLiquidateNotExceedDebtToCover` - Debt to liquidate bounded by debt to cover + - `collateralToLiquidatorNotExceedTotal` - Collateral seized bounded by total + - `collateralToLiquidateValueLessThanDebtToLiquidate_assets` - Collateral value <= debt value (assets) + - `collateralToLiquidateValueLessThanDebtToLiquidate_shares` - Collateral value <= debt value (shares) + - `collateralToLiquidateValueLessThanDebtToLiquidate_fullRayDebt` - Collateral value <= debt value (full ray) + +### `libs/LiquidationLogic_Bonus.spec` + +**Liquidation bonus calculation verification.** + +- **Config:** `certora/conf/libs/LiquidationLogic_Bonus.conf` +- **Harness:** `LiquidationLogicHarness.sol` +- **Purpose:** Verifies `calculateLiquidationBonus` function properties +- **Key Rules:** + - `sanityCheck` - Basic sanity check + - `maxBonusWhenLowHealthFactor` - Max bonus when health factor <= threshold for max bonus + - `bonusIsAtLeastNoBonus` - Bonus >= PERCENTAGE_FACTOR (no negative bonus) + - `bonusDoesNotExceedMax` - Bonus <= max liquidation bonus + - `monotonicityOfBonus` - Lower health factor → higher bonus + - `bonusAtThreshold` - Bonus equals PERCENTAGE_FACTOR at liquidation threshold + - `zeroBonusFactorMeansNoMinBonus` - Zero bonus factor means no minimum bonus + +### `libs/DebtToTarget.spec` + +**Debt to target health factor calculation verification.** + +- **Config:** `certora/conf/libs/DebtToTarget.conf` +- **Harness:** `SpokeHarness.sol` +- **Purpose:** Verifies `_calculateDebtToTargetHealthFactor` function properties + +### `libs/ProcessUserAccountData.spec` + +**User account data processing verification.** + +- **Config:** `certora/conf/libs/ProcessUserAccountData.conf` +- **Harness:** `SpokeHarness.sol` +- **Purpose:** Verifies `_processUserAccountData` function properties + +### `libs/LibBit.spec` + +**Bit manipulation library verification.** + +- **Config:** `certora/conf/libs/LibBit.conf` + +### `libs/PositionStatus.spec` + +**Position status verification.** + +- **Config:** `certora/conf/libs/PositionStatus.conf` + +### `libs/Premium.spec` + +**Premium calculation verification.** + +- **Config:** `certora/conf/libs/Premium.conf` +- **Imports:** `HubBase.spec`, `common.spec` +- **Purpose:** Verifies that `Premium.calculatePremiumRay` matches its CVL summarization +- **Key Rules:** + - `calculatePremiumRay_equivalence` - Solidity matches CVL implementation + +--- + +## Symbolic Representations + +### `symbolicRepresentation/Math_CVL.spec` + +**CVL implementations of math functions.** + +- **Purpose:** Provides CVL equivalents of Solidity math functions for use in summaries +- **Functions:** `mulDivDownCVL`, `mulDivUpCVL`, `mulDivRayDownCVL`, `mulDivRayUpCVL`, `divRayUpCVL`, `mulRayCVL` + +### `symbolicRepresentation/ERC20s_CVL.spec` + +**ERC20 symbolic representations.** + +- **Purpose:** Symbolic handling of ERC20 token interactions + +### `symbolicRepresentation/SymbolicHub.spec` + +**Symbolic Hub for Spoke verification.** + +- **Purpose:** Allows verifying Spoke independently of Hub implementation +- **Key Features:** + - Ghost mappings for asset indices per block + - CVL implementations of Hub functions (add, remove, draw, restore, etc.) + - Asset underlying address tracking + +### `symbolicRepresentation/SymbolicPositionStatus.spec` + +**Symbolic position status handling.** + +- **Purpose:** Provides ghost mappings and CVL functions for position status +- **Key Features:** + - `isBorrowing` and `isUsingAsCollateral` ghost mappings + - `nextBorrowingCVL` and `nextCollateralCVL` iteration functions + - Method summaries for PositionStatusMap library functions + +### `symbolicRepresentation/VerifySymbolicPositionStatus.spec` + +**Verification of symbolic position status.** + +- **Config:** `certora/conf/libs/VerifySymbolicPositionStatus.conf` +- **Purpose:** Verifies that symbolic representations match actual implementations + +--- + +## Common Specifications + +### `common.spec` + +**Shared method summaries.** + +- **Purpose:** Common summaries used in both Hub and Spoke specifications +- **Key Summaries:** + - `mulDivDown`, `mulDivUp` → CVL implementations + - `rayMulDown`, `rayMulUp`, `rayDivDown` → CVL implementations + - `wadDivUp`, `wadDivDown` → CVL implementations + - `percentMulDown`, `percentMulUp` → CVL implementations +- **Ghost Variables:** + - `RAY` = 10^27 + - `WAD` = 10^18 + - `PERCENTAGE_FACTOR` = 10000 + +--- + +## Dependency Graph + +``` +common.spec + ├── HubBase.spec + │ ├── Hub.spec + │ ├── HubValidState.spec + │ │ ├── HubIntegrity.spec + │ │ └── SpokeHubIntegrity.spec + │ ├── HubAccrueIntegrity.spec + │ ├── HubAccrueSupplyRate.spec + │ ├── HubAccrueUnrealizedFee.spec + │ └── HubAdditivity.spec (via SharesMath.spec) + │ + └── SpokeBaseSummaries.spec + └── SpokeBase.spec + ├── Spoke.spec + ├── SpokeIntegrity.spec + ├── SpokeHealthCheck.spec + ├── SpokeHealthFactor.spec + ├── SpokeUserIntegrity.spec + ├── SpokeHubIntegrity.spec + ├── Liquidation.spec + └── LiquidationUserIntegrity.spec + +symbolicRepresentation/ + ├── Math_CVL.spec (used by most specs) + ├── ERC20s_CVL.spec (used by most specs) + ├── SymbolicHub.spec (used by Spoke specs) + ├── SymbolicPositionStatus.spec (used by Spoke specs) + └── VerifySymbolicPositionStatus.spec + +libs/ + ├── Math.spec + ├── SharesMath.spec + ├── LibBit.spec + ├── PositionStatus.spec + ├── Premium.spec + ├── LiquidationLogic.spec + └── LiquidationLogic_Bonus.spec +``` + +--- + +## Harness Contracts + +### `HubHarness.sol` + +Exposes internal Hub functions for verification: + +- `accrueInterest()` - Exposes `AssetLogic.accrue()` + +### `MathWrapper.sol` + +Wraps math library functions for direct verification: + +- Exposes `WadRayMath` functions: `rayMulDown`, `rayMulUp`, `rayDivDown`, `rayDivUp`, `wadDivDown`, `wadDivUp` +- Exposes `MathUtils` functions: `mulDivDown`, `mulDivUp` +- Exposes `PercentageMath` functions: `percentMulDown`, `percentMulUp` + +### `LibBitHarness.sol` + +Wraps LibBit library for verification. + +### `PremiumWrapper.sol` + +Wraps Premium library for verification: + +- Exposes `Premium.calculatePremiumRay()` for CVL equivalence testing + +### `LiquidationLogicHarness.sol` + +Wraps LiquidationLogic library for verification: + +- Exposes `_calculateLiquidationAmounts()` for liquidation amount verification +- Exposes `calculateLiquidationBonus()` for bonus calculation verification + +### `SpokeHarness.sol` + +Wraps Spoke functions for verification: + +- Exposes `_calculateDebtToTargetHealthFactor()` for debt calculation verification +- Exposes `_processUserAccountData()` for account data processing verification + +--- + +## Additional Tips for Running Verification + +1. **Use Build Cache:** Most conf files have `"build_cache": true` to speed up repeated runs. + +2. **Split Long-Running Rules:** Use `--split_rules` for rules that may timeout: + + ```bash + certoraRun certora/conf/Spoke.conf --split_rules drawnSharesZero + ``` + +3. **Run Specific Rules:** Use `--msg` to label a run: + + ```bash + certoraRun certora/conf/Hub.conf --rule totalAssetsCompareToSuppliedAmount --msg "Hub totalAssets" + ``` + + **Run Rules on Specific Methods:** Use `--method` flag to run a parametric rule/invariant on a single method: + + ```bash + certoraRun certora/conf/Hub.conf --rule supplyExchangeRateIsMonotonic --method eliminateDeficit(uint256,uint256,address) --rule_sanity none --msg "Hub supplyExchangeRateIsMonotonic eliminateDeficit" + ``` + +4. View Results: Check the Certora Prover dashboard at https://prover.certora.com diff --git a/certora/conf/Hub.conf b/certora/conf/Hub.conf new file mode 100644 index 000000000..e0f0db544 --- /dev/null +++ b/certora/conf/Hub.conf @@ -0,0 +1,17 @@ +{ + "files": [ + "src/hub/Hub.sol", + ], + "verify": "Hub:certora/spec/Hub.spec", + "optimistic_loop": true, + "loop_iter": "3", + "rule_sanity" : "basic", + "msg": "AAVE V4 Hub", + "parametric_contracts" : "Hub", + "solc_via_ir" : true, + "build_cache" : true, + "smt_timeout": "7200", + // rules that are timing out and processed separately + //"split_rules": ["noChangeToOtherSpoke","supplyExchangeRateIsMonotonic"], + "independent_satisfy" : true + } \ No newline at end of file diff --git a/certora/conf/HubAccrueIntegrity.conf b/certora/conf/HubAccrueIntegrity.conf new file mode 100644 index 000000000..4ff28860a --- /dev/null +++ b/certora/conf/HubAccrueIntegrity.conf @@ -0,0 +1,27 @@ +{ + "files": [ + "certora/harness/HubHarness.sol", + "certora/harness/MathWrapper.sol" + ], + "verify": "HubHarness:certora/spec/HubAccrueIntegrity.spec", + "optimistic_loop": true, + "loop_iter": "3", + "rule_sanity" : "basic", + "msg": "HubHarness - AccrueIntegrity", + "parametric_contracts" : ["HubHarness"], + "solc_via_ir" : true, + "prover_args": [ + "-oldSplitParallel", + "true", + "-dontStopAtFirstSplitTimeout", + "true", + "-splitParallelTimelimit", + "7000", + "-splitParallelInitialDepth", + "3", + "-numOfParallelSplits", + "7" + ], + "independent_satisfy" : true, + "build_cache": true + } \ No newline at end of file diff --git a/certora/conf/HubAccrueSupplyRate.conf b/certora/conf/HubAccrueSupplyRate.conf new file mode 100644 index 000000000..f933156a1 --- /dev/null +++ b/certora/conf/HubAccrueSupplyRate.conf @@ -0,0 +1,17 @@ +{ + "files": [ + "certora/harness/HubHarness.sol" + ], + "loop_iter": 3, + "msg": "HubAccrue supply rate", + "multi_assert_check": true, + "optimistic_loop": true, + "parametric_contracts": [ + "HubHarness" + ], + // "split_rules" :["previewAddByShares_withoutAccrue_time_monotonic", "previewRemoveByShares_withoutAccrue_time_monotonic", "previewRemoveByAssets_withoutAccrue_time_monotonic"] + "smt_timeout": 7200, + "solc_via_ir": true, + "verify": "HubHarness:certora/spec/HubAccrueSupplyRate.spec", + "build_cache": true +} \ No newline at end of file diff --git a/certora/conf/HubAccrueUnrealizedFee.conf b/certora/conf/HubAccrueUnrealizedFee.conf new file mode 100644 index 000000000..7af3864ca --- /dev/null +++ b/certora/conf/HubAccrueUnrealizedFee.conf @@ -0,0 +1,13 @@ +{ + "files": [ + "certora/harness/HubHarness.sol" + ], + "verify": "HubHarness:certora/spec/HubAccrueUnrealizedFee.spec", + "optimistic_loop": true, + "loop_iter": "3", + "rule_sanity" : "basic", + "msg": "HubHarness - AccrueIntegrity Unrealized Fee", + "parametric_contracts" : ["HubHarness"], + "solc_via_ir" : true, + "build_cache": true + } \ No newline at end of file diff --git a/certora/conf/HubAdditivity.conf b/certora/conf/HubAdditivity.conf new file mode 100644 index 000000000..26b9109e4 --- /dev/null +++ b/certora/conf/HubAdditivity.conf @@ -0,0 +1,14 @@ +{ + "files": [ + "src/hub/Hub.sol", + ], + "verify": "Hub:certora/spec/HubAdditivity.spec", + "optimistic_loop": true, + "loop_iter": "1", + "rule_sanity" : "none", + "msg": "Hub Additivity", + "parametric_contracts" : "Hub", + "solc_via_ir" : true, + "independent_satisfy" : true, + "build_cache": true, + } \ No newline at end of file diff --git a/certora/conf/HubIntegrity.conf b/certora/conf/HubIntegrity.conf new file mode 100644 index 000000000..de5068496 --- /dev/null +++ b/certora/conf/HubIntegrity.conf @@ -0,0 +1,14 @@ +{ + "files": [ + "src/hub/Hub.sol" + ], + "verify": "Hub:certora/spec/HubIntegrity.spec", + "optimistic_loop": true, + "loop_iter": "3", + "rule_sanity" : "basic", + "msg": "Hub integrityRules", + "parametric_contracts" : "Hub", + "solc_via_ir" : true, + "independent_satisfy" : true, + "build_cache": true + } \ No newline at end of file diff --git a/certora/conf/HubValidState.conf b/certora/conf/HubValidState.conf new file mode 100644 index 000000000..4bf5d97c7 --- /dev/null +++ b/certora/conf/HubValidState.conf @@ -0,0 +1,15 @@ +{ + "files": [ + "src/hub/Hub.sol" + ], + "verify": "Hub:certora/spec/HubValidState.spec", + "optimistic_loop": true, + "loop_iter": "3", + "rule_sanity" : "basic", + "msg": "AAVE V4 Hub - validState invariants", + "parametric_contracts" : "Hub", + "solc_via_ir" : true, + "build_cache" : true, + //"exclude_rule": ["totalAssetsVsShares", "totalAssetsVsShares_eliminateDeficit"], + "smt_timeout": "7200", + } \ No newline at end of file diff --git a/certora/conf/HubValidState_totalAssets.conf b/certora/conf/HubValidState_totalAssets.conf new file mode 100644 index 000000000..9becf463f --- /dev/null +++ b/certora/conf/HubValidState_totalAssets.conf @@ -0,0 +1,18 @@ +{ + "files": [ + "src/hub/Hub.sol" + ], + "verify": "Hub:certora/spec/HubValidState.spec", + "optimistic_loop": true, + "loop_iter": "3", + "rule_sanity" : "none", + "msg": "AAVE V4 Hub - totalAssetsVsShares ", + "parametric_contracts" : "Hub", + "solc_via_ir" : true, + "build_cache" : true, + "smt_timeout": "7200", + "rule" : "totalAssetsVsShares", + "prover_args": [ + " -destructiveOptimizations twostage -splitParallel true -splitParallelInitialDepth 4 -splitParallelTimelimit 7200" + ], + } \ No newline at end of file diff --git a/certora/conf/Liquidation.conf b/certora/conf/Liquidation.conf new file mode 100644 index 000000000..89b012812 --- /dev/null +++ b/certora/conf/Liquidation.conf @@ -0,0 +1,20 @@ +{ + "files": [ + "src/spoke/instances/SpokeInstance.sol" + ], + "msg": "Liquidation", + "optimistic_hashing": true, + "optimistic_loop": true, + "loop_iter": "1", + "packages": [ + "prettier=node_modules/prettier", + "prettier-plugin-solidity=node_modules/prettier-plugin-solidity" + ], + "parametric_contracts": [ + "SpokeInstance" + ], + "independent_satisfy" : true, + "verify": "SpokeInstance:certora/spec/Liquidation.spec", + "build_cache" : true +} + diff --git a/certora/conf/LiquidationIntegrity.conf b/certora/conf/LiquidationIntegrity.conf new file mode 100644 index 000000000..edaaec18f --- /dev/null +++ b/certora/conf/LiquidationIntegrity.conf @@ -0,0 +1,20 @@ +{ + "files": [ + "src/spoke/instances/SpokeInstance.sol", + "certora/harness/LiquidationLogicHarness.sol" + ], + "msg": "Liquidation Integrity", + "optimistic_hashing": true, + "optimistic_loop": true, + "loop_iter": "1", + "packages": [ + "prettier=node_modules/prettier", + "prettier-plugin-solidity=node_modules/prettier-plugin-solidity" + ], + "parametric_contracts": [ + "SpokeInstance" + ], + "independent_satisfy" : true, + "verify": "SpokeInstance:certora/spec/LiquidationIntegrity.spec", + "build_cache" : true +} diff --git a/certora/conf/LiquidationLogic_caculatedDebt b/certora/conf/LiquidationLogic_caculatedDebt new file mode 100644 index 000000000..d3d82a686 --- /dev/null +++ b/certora/conf/LiquidationLogic_caculatedDebt @@ -0,0 +1,18 @@ +{ + "build_cache": true, + "files": [ + "certora/harness/LiquidationLogicHarness.sol" + ], + "global_timeout": 7200, + "group_id": "09aff96d-b64a-4607-a69c-b6e18cb4e83a", + "msg": "Parallel splitter", + "prover_args": [ + " -destructiveOptimizations twostage -splitParallel true -splitParallelInitialDepth 4 -splitParallelTimelimit 7200" + ], + "rule": [ + "computedDebtRayToLiquidateIsLessThanTotalDebtValue" + ], + "rule_sanity": "basic", + "smt_timeout": 7200, + "verify": "LiquidationLogicHarness:certora/spec/libs/LiquidationLogic.spec" +} \ No newline at end of file diff --git a/certora/conf/LiquidationReportDeficit.conf b/certora/conf/LiquidationReportDeficit.conf new file mode 100644 index 000000000..714744115 --- /dev/null +++ b/certora/conf/LiquidationReportDeficit.conf @@ -0,0 +1,19 @@ +{ + "files": [ + "src/spoke/instances/SpokeInstance.sol" + ], + "msg": "LiquidationReportDeficit", + "optimistic_hashing": true, + "optimistic_loop": true, + "loop_iter": "3", + "packages": [ + "prettier=node_modules/prettier", + "prettier-plugin-solidity=node_modules/prettier-plugin-solidity" + ], + "parametric_contracts": [ + "SpokeInstance" + ], + "independent_satisfy": true, + "verify": "SpokeInstance:certora/spec/LiquidationReportDeficit.spec", + "build_cache": true +} diff --git a/certora/conf/LiquidationUserIntegrity.conf b/certora/conf/LiquidationUserIntegrity.conf new file mode 100644 index 000000000..7409fabf0 --- /dev/null +++ b/certora/conf/LiquidationUserIntegrity.conf @@ -0,0 +1,30 @@ +{ + "files": [ + "src/spoke/instances/SpokeInstance.sol" + ], + "msg": "LiquidationUserIntegrity", + "optimistic_hashing": true, + "optimistic_loop": true, + "loop_iter": "3", + "packages": [ + "prettier=node_modules/prettier", + "prettier-plugin-solidity=node_modules/prettier-plugin-solidity" + ], + "parametric_contracts": [ + "SpokeInstance" + ], + "verify": "SpokeInstance:certora/spec/LiquidationUserIntegrity.spec", + "build_cache": true, + "prover_args": [ + "-destructiveOptimizations", + "twostage", + "-mediumTimeout", + "2", + "-splitParallel", + "true", + "-dontStopAtFirstSplitTimeout", + "true" + ], + "independent_satisfy": true +} + diff --git a/certora/conf/Spoke.conf b/certora/conf/Spoke.conf new file mode 100644 index 000000000..2814fb672 --- /dev/null +++ b/certora/conf/Spoke.conf @@ -0,0 +1,22 @@ +{ + "files": [ + "src/spoke/instances/SpokeInstance.sol" + ], + "msg": "Spoke", + "optimistic_hashing": true, + "optimistic_loop": true, + "loop_iter": "3", + "packages": [ + "prettier=node_modules/prettier", + "prettier-plugin-solidity=node_modules/prettier-plugin-solidity" + ], + "parametric_contracts": [ + "SpokeInstance" + ], + "independent_satisfy" : true, + "verify": "SpokeInstance:certora/spec/Spoke.spec", + // when running manually better to split the rules + //"split_rules" : ["drawnSharesZero"], + "exclude_rule" : ["noCollateralNoDebt"], + "build_cache" : true +} diff --git a/certora/conf/SpokeHealthCheck.conf b/certora/conf/SpokeHealthCheck.conf new file mode 100644 index 000000000..d1d201faf --- /dev/null +++ b/certora/conf/SpokeHealthCheck.conf @@ -0,0 +1,19 @@ +{ + "files": [ + "src/spoke/instances/SpokeInstance.sol" + ], + "msg": "SpokeHealthCheck", + "optimistic_hashing": true, + "optimistic_loop": true, + "loop_iter": "3", + "packages": [ + "prettier=node_modules/prettier", + "prettier-plugin-solidity=node_modules/prettier-plugin-solidity" + ], + "parametric_contracts": [ + "SpokeInstance" + ], + "verify": "SpokeInstance:certora/spec/SpokeHealthCheck.spec", + "build_cache" : true +} + diff --git a/certora/conf/SpokeHealthFactor.conf b/certora/conf/SpokeHealthFactor.conf new file mode 100644 index 000000000..282031f59 --- /dev/null +++ b/certora/conf/SpokeHealthFactor.conf @@ -0,0 +1,20 @@ +{ + "files": [ + "src/spoke/instances/SpokeInstance.sol" + ], + "msg": "SpokeHealthCheck_take2", + "optimistic_hashing": true, + "optimistic_loop": true, + "loop_iter": "3", + "packages": [ + "prettier=node_modules/prettier", + "prettier-plugin-solidity=node_modules/prettier-plugin-solidity" + ], + "parametric_contracts": [ + "SpokeInstance" + ], + "independent_satisfy" : true, + "verify": "SpokeInstance:certora/spec/SpokeHealthFactor.spec", + "build_cache" : true +} + diff --git a/certora/conf/SpokeIntegrity.conf b/certora/conf/SpokeIntegrity.conf new file mode 100644 index 000000000..f70f4b936 --- /dev/null +++ b/certora/conf/SpokeIntegrity.conf @@ -0,0 +1,21 @@ +{ + "files": [ + "src/spoke/instances/SpokeInstance.sol" + ], + "msg": "Spoke Integrity", + "optimistic_hashing": true, + "optimistic_loop": true, + "loop_iter": "3", + //"solc_via_ir" : true, + "packages": [ + "prettier=node_modules/prettier", + "prettier-plugin-solidity=node_modules/prettier-plugin-solidity" + ], + "parametric_contracts": [ + "SpokeInstance" + ], + "independent_satisfy" : true, + "verify": "SpokeInstance:certora/spec/SpokeIntegrity.spec", + "build_cache" : true +} + diff --git a/certora/conf/SpokeUserIntegrity.conf b/certora/conf/SpokeUserIntegrity.conf new file mode 100644 index 000000000..014dc2993 --- /dev/null +++ b/certora/conf/SpokeUserIntegrity.conf @@ -0,0 +1,18 @@ +{ + "files": [ + "src/spoke/instances/SpokeInstance.sol", + ], + "msg": "SpokeUserIntegrity", + "optimistic_hashing": true, + "optimistic_loop": true, + "loop_iter": "3", + "packages": [ + "prettier=node_modules/prettier", + "prettier-plugin-solidity=node_modules/prettier-plugin-solidity" + ], + "parametric_contracts": [ + "SpokeInstance" + ], + "verify": "SpokeInstance:certora/spec/SpokeUserIntegrity.spec", + "build_cache" : true +} \ No newline at end of file diff --git a/certora/conf/SpokeWithHub.conf b/certora/conf/SpokeWithHub.conf new file mode 100644 index 000000000..bfbebe29a --- /dev/null +++ b/certora/conf/SpokeWithHub.conf @@ -0,0 +1,30 @@ +{ + "files": [ + "src/hub/Hub.sol", + "src/spoke/instances/SpokeInstance.sol" + ], + "optimistic_hashing": true, + "optimistic_loop": true, + "loop_iter": "1", + "packages": [ + "prettier=node_modules/prettier", + "prettier-plugin-solidity=node_modules/prettier-plugin-solidity" + ], + "parametric_contracts": [ "SpokeInstance", "Hub" ], + "verify": "SpokeInstance:certora/spec/SpokeHubIntegrity.spec", + "msg": "Spoke Hub Integrity", + "build_cache" : true, + "struct_link": [ + "SpokeInstance:hub=Hub", + ], + "independent_satisfy" : true, + // when running manually better to split the rules + //"split_rules" : ["user*"], + "prover_args": [ + "-splitParallel", + "true", + "-dontStopAtFirstSplitTimeout", + "true" + ], + +} \ No newline at end of file diff --git a/certora/conf/Spoke_noCollateralNoDebt.conf b/certora/conf/Spoke_noCollateralNoDebt.conf new file mode 100644 index 000000000..2b19e90e3 --- /dev/null +++ b/certora/conf/Spoke_noCollateralNoDebt.conf @@ -0,0 +1,27 @@ +{ + "files": [ + "src/spoke/instances/SpokeInstance.sol" + ], + "msg": "Spoke noCollateralNoDebt", + "optimistic_hashing": true, + "optimistic_loop": true, + "loop_iter": "3", + "packages": [ + "prettier=node_modules/prettier", + "prettier-plugin-solidity=node_modules/prettier-plugin-solidity" + ], + "parametric_contracts": [ + "SpokeInstance" + ], + "independent_satisfy" : true, + "verify": "SpokeInstance:certora/spec/Spoke.spec", + "rule": ["noCollateralNoDebt"], + "build_cache" : true, + "prover_args": [ + "-splitParallel", + "true", + "-dontStopAtFirstSplitTimeout", + "true" + ] +} + diff --git a/certora/conf/libs/LibBit.conf b/certora/conf/libs/LibBit.conf new file mode 100644 index 000000000..51f90c03a --- /dev/null +++ b/certora/conf/libs/LibBit.conf @@ -0,0 +1,14 @@ +{ + "files": [ + "certora/harness/LibBitHarness.sol" + ], + "precise_bitwise_ops" : true, + "msg" : "LibBit library", + "verify": "LibBitHarness:certora/spec/libs/LibBit.spec", + "smt_timeout": "7200", + "prover_args": [ + "-split", + "false" + ], + "build_cache": true +} \ No newline at end of file diff --git a/certora/conf/libs/LiquidationLogic.conf b/certora/conf/libs/LiquidationLogic.conf new file mode 100644 index 000000000..e68270ccb --- /dev/null +++ b/certora/conf/libs/LiquidationLogic.conf @@ -0,0 +1,13 @@ +{ + "files": [ + "certora/harness/LiquidationLogicHarness.sol", + "src/dependencies/openzeppelin/Math.sol", + "src/libraries/math/PercentageMath.sol" + ], + "verify": "LiquidationLogicHarness:certora/spec/libs/LiquidationLogic.spec", + "rule_sanity": "basic", + "msg": "LiquidationLogic calculateLiquidationAmounts", + //"split_rules": ["collateralToLiquidateValueLessThanDebtToLiquidate", //"collateralToLiquidateValueLessThanDebtToLiquidate_general"], + "build_cache": true +} + diff --git a/certora/conf/libs/LiquidationLogic_Bonus.conf b/certora/conf/libs/LiquidationLogic_Bonus.conf new file mode 100644 index 000000000..6cb8ac9f5 --- /dev/null +++ b/certora/conf/libs/LiquidationLogic_Bonus.conf @@ -0,0 +1,12 @@ +{ + "files": [ + "certora/harness/LiquidationLogicHarness.sol", + "src/dependencies/openzeppelin/Math.sol", + "src/libraries/math/PercentageMath.sol" + ], + "verify": "LiquidationLogicHarness:certora/spec/libs/LiquidationLogic_Bonus.spec", + "rule_sanity" : "basic", + "msg": "LiquidationLogic calculateLiquidationBonus", + "build_cache": true +} + diff --git a/certora/conf/libs/LiquidationLogic_debtToLiquidate.conf b/certora/conf/libs/LiquidationLogic_debtToLiquidate.conf new file mode 100644 index 000000000..00535fbf8 --- /dev/null +++ b/certora/conf/libs/LiquidationLogic_debtToLiquidate.conf @@ -0,0 +1,18 @@ +{ + "files": [ + "certora/harness/LiquidationLogicHarness.sol", + "src/dependencies/openzeppelin/Math.sol", + "src/libraries/math/PercentageMath.sol", + "src/libraries/math/WadRayMath.sol", + "src/libraries/math/MathUtils.sol" + ], + "verify": "LiquidationLogicHarness:certora/spec/libs/LiquidationLogic_debtToLiquidate.spec", + "rule_sanity": "basic", + "msg": "LiquidationLogic calculateDebtToLiquidate", + "packages": [ + "prettier=node_modules/prettier", + "prettier-plugin-solidity=node_modules/prettier-plugin-solidity" + ], + "build_cache": true, + "optimistic_loop": true +} diff --git a/certora/conf/libs/Math.conf b/certora/conf/libs/Math.conf new file mode 100644 index 000000000..18f6b1ffe --- /dev/null +++ b/certora/conf/libs/Math.conf @@ -0,0 +1,9 @@ +{ + "files": [ + "certora/harness/MathWrapper.sol" + ], + "verify": "MathWrapper:certora/spec/libs/Math.spec", + "rule_sanity" : "basic", + "msg": "Math function equivalence", + "build_cache": true + } \ No newline at end of file diff --git a/certora/conf/libs/PositionStatus.conf b/certora/conf/libs/PositionStatus.conf new file mode 100644 index 000000000..bc96b87a3 --- /dev/null +++ b/certora/conf/libs/PositionStatus.conf @@ -0,0 +1,12 @@ +{ + "files": [ + "tests/mocks/PositionStatusMapWrapper.sol" + ], + "precise_bitwise_ops" : true, + "msg" : "PositionStatus library", + "verify": "PositionStatusMapWrapper:certora/spec/libs/PositionStatus.spec", + "smt_timeout": "7200", + "loop_iter": "3", + "optimistic_loop": true, + "build_cache": true +} diff --git a/certora/conf/libs/Premium.conf b/certora/conf/libs/Premium.conf new file mode 100644 index 000000000..b17954c7c --- /dev/null +++ b/certora/conf/libs/Premium.conf @@ -0,0 +1,10 @@ +{ + "files": [ + "certora/harness/PremiumWrapper.sol" + ], + "verify": "PremiumWrapper:certora/spec/libs/Premium.spec", + "rule_sanity" : "basic", + "msg": "Premium calculatePremiumRay equivalence", + "build_cache": true +} + diff --git a/certora/conf/libs/SharesMath.conf b/certora/conf/libs/SharesMath.conf new file mode 100644 index 000000000..b7252ec76 --- /dev/null +++ b/certora/conf/libs/SharesMath.conf @@ -0,0 +1,17 @@ +{ + "files": [ + "certora/harness/HubHarness.sol" + ], + "verify": "HubHarness:certora/spec/libs/SharesMath.spec", + "optimistic_loop": true, + "loop_iter": "3", + "rule_sanity" : "basic", + "msg": "HubHarness - SharesMath", + "parametric_contracts" : ["HubHarness"], + "solc_via_ir" : true, + "independent_satisfy" : true, + "build_cache" : true, + "prover_args": [ + " -destructiveOptimizations twostage -backendStrategy singleRace -smt_useLIA false -smt_useNIA true -depth 0 -s [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}]" + ], + } \ No newline at end of file diff --git a/certora/conf/libs/SpokeUtils_toValue.conf b/certora/conf/libs/SpokeUtils_toValue.conf new file mode 100644 index 000000000..892f5ba5c --- /dev/null +++ b/certora/conf/libs/SpokeUtils_toValue.conf @@ -0,0 +1,17 @@ +{ + "files": [ + "certora/harness/SpokeUtilsHarness.sol", + "src/dependencies/openzeppelin/Math.sol", + "src/libraries/math/PercentageMath.sol", + "src/libraries/math/WadRayMath.sol", + "src/libraries/math/MathUtils.sol" + ], + "verify": "SpokeUtilsHarness:certora/spec/libs/SpokeUtils_toValue.spec", + "rule_sanity": "basic", + "msg": "SpokeUtils toValue", + "packages": [ + "prettier=node_modules/prettier", + "prettier-plugin-solidity=node_modules/prettier-plugin-solidity" + ], + "build_cache": true +} diff --git a/certora/conf/libs/VerifySymbolicPositionStatus.conf b/certora/conf/libs/VerifySymbolicPositionStatus.conf new file mode 100644 index 000000000..6986c0af9 --- /dev/null +++ b/certora/conf/libs/VerifySymbolicPositionStatus.conf @@ -0,0 +1,17 @@ +{ + "files": [ + "tests/mocks/PositionStatusMapWrapper.sol", + "src/spoke/instances/SpokeInstance.sol", + ], + "precise_bitwise_ops" : true, + "msg" : "PositionStatus check summary", + "verify": "PositionStatusMapWrapper:certora/spec/symbolicRepresentation/VerifySymbolicPositionStatus.spec", + "smt_timeout": "7200", + "loop_iter": "3", + "optimistic_loop": true, + "prover_args": [ + "-split", + "false" + ], + "build_cache": true +} diff --git a/certora/harness/HubHarness.sol b/certora/harness/HubHarness.sol new file mode 100644 index 000000000..86f50b06a --- /dev/null +++ b/certora/harness/HubHarness.sol @@ -0,0 +1,57 @@ +import '../../src/hub/Hub.sol'; +import {AssetLogic} from 'src/hub/libraries/AssetLogic.sol'; +import {SharesMath} from 'src/hub/libraries/SharesMath.sol'; + +pragma solidity ^0.8.0; + +contract HubHarness is Hub { + using AssetLogic for Asset; + + constructor(address authority_) Hub(authority_) { + // Intentionally left blank + } + + function accrueInterest(uint256 assetId) external { + Asset storage asset = _assets[assetId]; + + asset.accrue(); + } + + function toSharesDown( + uint256 assets, + uint256 totalAssets, + uint256 totalShares + ) external pure returns (uint256) { + return SharesMath.toSharesDown(assets, totalAssets, totalShares); + } + + function toAssetsDown( + uint256 shares, + uint256 totalAssets, + uint256 totalShares + ) external pure returns (uint256) { + return SharesMath.toAssetsDown(shares, totalAssets, totalShares); + } + + function toSharesUp( + uint256 assets, + uint256 totalAssets, + uint256 totalShares + ) external pure returns (uint256) { + return SharesMath.toSharesUp(assets, totalAssets, totalShares); + } + + function toAssetsUp( + uint256 shares, + uint256 totalAssets, + uint256 totalShares + ) external pure returns (uint256) { + return SharesMath.toAssetsUp(shares, totalAssets, totalShares); + } + + function getUnrealizedFees(uint256 assetId) external view returns (uint256) { + Asset storage asset = _assets[assetId]; + + return asset.getUnrealizedFees(asset.getDrawnIndex()); + } +} diff --git a/certora/harness/LibBitHarness.sol b/certora/harness/LibBitHarness.sol new file mode 100644 index 000000000..b50368c21 --- /dev/null +++ b/certora/harness/LibBitHarness.sol @@ -0,0 +1,25 @@ +import {LibBit} from '../../src/dependencies/solady/LibBit.sol'; + +pragma solidity ^0.8.0; + +contract LibBitHarness { + function popCount(uint256 x) external pure returns (uint256 c) { + return LibBit.popCount(x); + } + + function fls(uint256 x) external pure returns (uint256 r) { + return LibBit.fls(x); + } + + function isBitTrue(uint256 x, uint16 pos) public pure returns (bool) { + return ((x >> pos) & 1) == 1; + } + + function changeOneBit(uint256 x, uint16 pos) external pure returns (uint256 c) { + if (isBitTrue(x, pos)) { + return x & ~(1 << pos); + } else { + return x | (1 << pos); + } + } +} diff --git a/certora/harness/LiquidationLogicHarness.sol b/certora/harness/LiquidationLogicHarness.sol new file mode 100644 index 000000000..0bf7d5be2 --- /dev/null +++ b/certora/harness/LiquidationLogicHarness.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {LiquidationLogic} from '../../src/spoke/libraries/LiquidationLogic.sol'; + +contract LiquidationLogicHarness { + function calculateLiquidationAmounts( + LiquidationLogic.CalculateLiquidationAmountsParams memory params + ) external view returns (LiquidationLogic.LiquidationAmounts memory) { + return LiquidationLogic._calculateLiquidationAmounts(params); + } + + function calculateLiquidationBonus( + uint256 healthFactorForMaxBonus, + uint256 liquidationBonusFactor, + uint256 healthFactor, + uint256 maxLiquidationBonus + ) external pure returns (uint256) { + return + LiquidationLogic.calculateLiquidationBonus( + healthFactorForMaxBonus, + liquidationBonusFactor, + healthFactor, + maxLiquidationBonus + ); + } + + function calculateDebtToLiquidate( + LiquidationLogic.CalculateDebtToLiquidateParams memory params + ) external pure returns (uint256, uint256) { + return LiquidationLogic._calculateDebtToLiquidate(params); + } +} diff --git a/certora/harness/MathWrapper.sol b/certora/harness/MathWrapper.sol new file mode 100644 index 000000000..c6b25bf9a --- /dev/null +++ b/certora/harness/MathWrapper.sol @@ -0,0 +1,101 @@ +import {Math} from '../../src/dependencies/openzeppelin/Math.sol'; +import {WadRayMath} from '../../src/libraries/math/WadRayMath.sol'; +import {PercentageMath} from '../../src/libraries/math/PercentageMath.sol'; +import {MathUtils} from '../../src/libraries/math/MathUtils.sol'; + +pragma solidity ^0.8.0; + +contract MathWrapper { + function SECONDS_PER_YEAR() external pure returns (uint256) { + return 365 days; + } + + function mulDiv( + uint256 x, + uint256 y, + uint256 denominator + ) external pure returns (uint256 result) { + return Math.mulDiv(x, y, denominator); + } + + function mulDiv( + uint256 x, + uint256 y, + uint256 denominator, + Math.Rounding rounding + ) external pure returns (uint256 result) { + return Math.mulDiv(x, y, denominator, rounding); + } + + function mulDivDown( + uint256 x, + uint256 y, + uint256 denominator + ) external pure returns (uint256 result) { + return MathUtils.mulDivDown(x, y, denominator); + } + + function mulDivUp( + uint256 x, + uint256 y, + uint256 denominator + ) external pure returns (uint256 result) { + return MathUtils.mulDivUp(x, y, denominator); + } + + function divUp(uint256 a, uint256 b) external pure returns (uint256 result) { + return MathUtils.divUp(a, b); + } + + function RAY() public pure returns (uint256) { + return WadRayMath.RAY; + } + + function WAD() public pure returns (uint256) { + return WadRayMath.WAD; + } + + function rayMulDown(uint256 a, uint256 b) public pure returns (uint256) { + return WadRayMath.rayMulDown(a, b); + } + + function rayMulUp(uint256 a, uint256 b) public pure returns (uint256) { + return WadRayMath.rayMulUp(a, b); + } + + function rayDivDown(uint256 a, uint256 b) public pure returns (uint256) { + return WadRayMath.rayDivDown(a, b); + } + + function rayDivUp(uint256 a, uint256 b) public pure returns (uint256) { + return WadRayMath.rayDivUp(a, b); + } + + function wadDivDown(uint256 a, uint256 b) public pure returns (uint256) { + return WadRayMath.wadDivDown(a, b); + } + + function wadDivUp(uint256 a, uint256 b) public pure returns (uint256) { + return WadRayMath.wadDivUp(a, b); + } + + function percentMulDown(uint256 percentage, uint256 value) public pure returns (uint256) { + return PercentageMath.percentMulDown(percentage, value); + } + + function percentMulUp(uint256 percentage, uint256 value) public pure returns (uint256) { + return PercentageMath.percentMulUp(percentage, value); + } + + function PERCENTAGE_FACTOR() public pure returns (uint256) { + return PercentageMath.PERCENTAGE_FACTOR; + } + + function fromRayUp(uint256 a) public pure returns (uint256) { + return WadRayMath.fromRayUp(a); + } + + function toRay(uint256 a) public pure returns (uint256) { + return WadRayMath.toRay(a); + } +} diff --git a/certora/harness/PremiumWrapper.sol b/certora/harness/PremiumWrapper.sol new file mode 100644 index 000000000..b381e0324 --- /dev/null +++ b/certora/harness/PremiumWrapper.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import {Premium} from '../../src/hub/libraries/Premium.sol'; + +contract PremiumWrapper { + function calculatePremiumRay( + uint256 premiumShares, + int256 premiumOffsetRay, + uint256 drawnIndex + ) external pure returns (uint256) { + return Premium.calculatePremiumRay(premiumShares, premiumOffsetRay, drawnIndex); + } +} diff --git a/certora/harness/SpokeHarness.sol b/certora/harness/SpokeHarness.sol new file mode 100644 index 000000000..226046536 --- /dev/null +++ b/certora/harness/SpokeHarness.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.28; + +import {SpokeInstance} from 'src/spoke/instances/SpokeInstance.sol'; +import {LiquidationLogic} from 'src/spoke/libraries/LiquidationLogic.sol'; +import {SpokeUtils} from '../../src/spoke/libraries/SpokeUtils.sol'; + +contract SpokeHarness is SpokeInstance { + constructor(address oracle_) SpokeInstance(oracle_) {} + + function calculateDebtToTargetHealthFactor( + LiquidationLogic.CalculateDebtToTargetHealthFactorParams memory params + ) external pure returns (uint256) { + return LiquidationLogic._calculateDebtToTargetHealthFactor(params); + } + + function calculateDebtToLiquidate( + LiquidationLogic.CalculateDebtToLiquidateParams memory params + ) external pure returns (uint256, uint256) { + return LiquidationLogic._calculateDebtToLiquidate(params); + } + + function processUserAccountData( + address user, + bool refreshConfig + ) external returns (UserAccountData memory) { + return _processUserAccountData(user, refreshConfig); + } +} diff --git a/certora/harness/SpokeUtilsHarness.sol b/certora/harness/SpokeUtilsHarness.sol new file mode 100644 index 000000000..b2d68d065 --- /dev/null +++ b/certora/harness/SpokeUtilsHarness.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {SpokeUtils} from '../../src/spoke/libraries/SpokeUtils.sol'; + +contract SpokeUtilsHarness { + function toValue( + uint256 amount, + uint256 decimals, + uint256 price + ) external pure returns (uint256) { + return SpokeUtils.toValue(amount, decimals, price); + } +} diff --git a/certora/spec/Hub.spec b/certora/spec/Hub.spec new file mode 100644 index 000000000..cb0ac4c89 --- /dev/null +++ b/certora/spec/Hub.spec @@ -0,0 +1,242 @@ + +/** + * @title Hub Contract Specification + * @notice State change rules in which the validate functions are ignored + * @dev Assumes accrue has been called on the current block timestamp + * @assumptions ERC20s are standard tokens, specifically, no fees on transfer and no callbacks + */ + +import "./symbolicRepresentation/ERC20s_CVL.spec"; +import "./symbolicRepresentation/Math_CVL.spec"; +import "./HubValidState.spec"; + +//////////////////////////////////////////////////////////////////////////// +// METHODS // +//////////////////////////////////////////////////////////////////////////// + +methods { + function _validateAdd( + IHub.Asset storage asset, + IHub.SpokeData storage spoke, + uint256 amount + ) internal => NONDET; + + function _validateRemove( + IHub.SpokeData storage spoke, + uint256 amount, + address to + ) internal => NONDET; + + function _validateDraw( + IHub.Asset storage asset, + IHub.SpokeData storage spoke, + uint256 amount, + address to + ) internal => NONDET; + + function _validateRestore( + IHub.Asset storage asset, + IHub.SpokeData storage spoke, + uint256 drawnAmount, + uint256 premiumAmountRay + ) internal => NONDET; + + function _validateReportDeficit( + IHub.Asset storage asset, + IHub.SpokeData storage spoke, + uint256 drawnAmount, + uint256 premiumAmountRay + ) internal => NONDET; + + function _validateEliminateDeficit( + IHub.SpokeData storage spoke, + uint256 amount + ) internal => NONDET; + + function _validatePayFeeShares( + IHub.SpokeData storage senderSpoke, + uint256 feeShares + ) internal => NONDET; + + function _validateTransferShares( + IHub.Asset storage asset, + IHub.SpokeData storage sender, + IHub.SpokeData storage receiver, + uint256 shares + ) internal => NONDET; + + function _validateSweep( + IHub.Asset storage asset, + address caller, + uint256 amount + ) internal => NONDET; + + function _validateReclaim( + IHub.Asset storage asset, + address caller, + uint256 amount + ) internal => NONDET; +} + +//////////////////////////////////////////////////////////////////////////// +// RULES // +//////////////////////////////////////////////////////////////////////////// + +/** + * @title Supply rate is never decreasing + * @notice When not accruing interest, every function should never decrease supply exchange rate + * @safe_assumptions accrue has been called on the current block timestamp + * supply exchange rate is monotonic increasing on accrue: proved in HubAccrueSupplyRate.spec + * @safe_assumption accrue is called before updating shares or debt: proved in rule accrueWasCalled + * @link_property share rate integrity + + */ +rule supplyExchangeRateIsMonotonic(env e, method f, calldataarg args) +filtered { + f -> !f.isView && f.selector != sig:eliminateDeficit(uint256,uint256,address).selector +} +{ + uint256 assetId; + uint256 OneM = 1000000; + + requireAllInvariants(assetId, e); + // use ghost to avoid repeating complex computation + mathint assetsBefore = addedAssetsBefore; + mathint sharesBefore = addedSharesBefore; + + require hub._assets[assetId].lastUpdateTimestamp == e.block.timestamp; + + f(e, args); + + mathint assetsAfter = getAddedAssets(e,assetId); + mathint sharesAfter = getAddedShares(e,assetId); + require assetsAfter >= sharesAfter, "based on rule totalAssetsVsShares(assetId,e) and to help the prover"; + assert (assetsAfter + OneM) * (sharesBefore + OneM) >= (assetsBefore + OneM) * (sharesAfter + OneM); +} + +// looking only at the deficit ray of the asset as other assets parts are assumed to be unchanged +rule supplyExchangeRateIsMonotonic_eliminateDeficit_simplified(uint256 assetId, env e) { + require hub._assets[assetId].lastUpdateTimestamp == e.block.timestamp; + requireAllInvariants(assetId, e); + address spokeId; + uint256 OneM = 1000000; + mathint assetsBefore = addedAssetsBefore; + mathint deficitBefore = divRayUpCVL(hub._assets[assetId].deficitRay); + require assetsBefore == deficitBefore; + mathint sharesBefore = addedSharesBefore; + + + uint256 amount; + require amount * RAY == hub._spokes[assetId][spokeId].deficitRay; + eliminateDeficit(e, assetId, amount, spokeId); + mathint deficitAfter = divRayUpCVL(hub._assets[assetId].deficitRay); + mathint sharesAfter = getAddedShares(e,assetId); + assert (deficitAfter + OneM) * (sharesBefore + OneM) >= (deficitBefore + OneM) * (sharesAfter + OneM); +} + +/** + * @title Can only increase a spoke's asset or decrease debt + * @notice Assumes accrue has been called + * @safe_assumption accrue does not change any values beside asset level : noChangeToOtherFields_accrue + * @link_property valid state changes + */ +rule noChangeToOtherSpoke(address spoke, uint256 assetId, address otherSpoke, method f) + filtered { f -> !f.isView } + { + env e; + + require otherSpoke != spoke && e.msg.sender == spoke; + require hub._assets[assetId].lastUpdateTimestamp == e.block.timestamp; + requireAllInvariants(assetId, e); + + + uint256 shares_ = getSpokeAddedShares(e, assetId, otherSpoke); + uint256 deficit_ = getSpokeDeficitRay(e, assetId, otherSpoke); + uint256 drawnShares_ = getSpokeDrawnShares(e, assetId, otherSpoke); + uint256 premiumShares_; int256 premiumOffset_; + premiumShares_, premiumOffset_ = getSpokePremiumData(e, assetId, otherSpoke); + + + calldataarg args; + f(e,args); + + // shares can increase on feeReceiver or transferShares function + assert shares_ <= getSpokeAddedShares(e, assetId, otherSpoke); + // deficit can decrease on eliminateDeficit function + assert deficit_ >= getSpokeDeficitRay(e, assetId, otherSpoke); + // drawn shares can not change + assert drawnShares_ == getSpokeDrawnShares(e, assetId, otherSpoke); + // premium shares and offset can not change + uint256 premiumSharesAfter; int256 premiumOffsetAfter; + premiumSharesAfter, premiumOffsetAfter = getSpokePremiumData(e, assetId, otherSpoke); + assert premiumShares_ == premiumSharesAfter; + assert premiumOffset_ == premiumOffsetAfter; +} + +/** + * @title Accrue must be called before updating shares or debt + * @notice Transferring shares is safe without accrue, as it stays the same behavior. + * Also adding an asset is safe without accrue, as there is nothing to update. + * @link_property accrue integrity + */ +rule accrueWasCalled(uint256 assetId, method f) filtered { f-> !f.isView && + f.selector != sig:addAsset(address,uint8,address,address,bytes).selector && + f.selector != sig:transferShares(uint256,uint256,address).selector} +{ + require !unsafeAccessBeforeAccrue; + + env e; + calldataarg args; + f(e,args); + + assert !unsafeAccessBeforeAccrue; + +} + +/** + * @title lastUpdateTimestamp is never updated if accrue was called already + * @link_property lastUpdateTimestamp state change + */ +rule lastUpdateTimestamp_notChanged(uint256 assetId, method f) filtered { f-> !f.isView} { + env e; + uint before = hub._assets[assetId].lastUpdateTimestamp; + + calldataarg args; + f(e,args); + + assert hub._assets[assetId].lastUpdateTimestamp == before || ( hub._assets[assetId].lastUpdateTimestamp == e.block.timestamp && f.selector == sig:addAsset(address,uint8,address,address,bytes).selector); + + +} + +/** + * @title Total assets is equal to the supplied amount when taking into account the virtual assets and shares + * @link_property preview functions integrity + */ +rule totalAssetsCompareToSuppliedAmount_virtual(uint256 assetId, env e){ + requireAllInvariants(assetId, e); + uint256 oneM = 1000000; + + mathint addedAssets = getAddedAssets(e,assetId) + oneM; + mathint addedShares = getAddedShares(e,assetId) + oneM; + + // rounding down + assert addedAssets == previewRemoveByShares(e, assetId, require_uint256(addedShares)); + // rounding up + assert addedAssets == previewAddByShares(e, assetId, require_uint256(addedShares)); +} + +/** + * @title Total assets is equal to or greater than the supplied amount without taking into account the virtual assets and shares + * @link_property preview functions integrity + */ +rule totalAssetsCompareToSuppliedAmount_noVirtual(uint256 assetId, env e){ + requireAllInvariants(assetId, e); + mathint addedAssets = getAddedAssets(e,assetId); + mathint addedShares = getAddedShares(e,assetId); + + assert addedAssets >= previewRemoveByShares(e, assetId, require_uint256(addedShares)); + satisfy addedAssets > previewRemoveByShares(e, assetId, require_uint256(addedShares)); + assert addedAssets >= previewAddByShares(e, assetId, require_uint256(addedShares)); + satisfy addedAssets > previewAddByShares(e, assetId, require_uint256(addedShares)); +} \ No newline at end of file diff --git a/certora/spec/HubAccrueIntegrity.spec b/certora/spec/HubAccrueIntegrity.spec new file mode 100644 index 000000000..254be04e1 --- /dev/null +++ b/certora/spec/HubAccrueIntegrity.spec @@ -0,0 +1,370 @@ +/** + * @title Hub Accrue Integrity Specification + * @notice Prove unit test properties of AssetLogic.accrue() function + * @dev This is proven on HubHarness which exposes accrue() as an external function + */ + +import "./HubBase.spec"; + +using HubHarness as hub; +using MathWrapper as mathWrapper; + +//////////////////////////////////////////////////////////////////////////// +// METHODS // +//////////////////////////////////////////////////////////////////////////// + +methods { + // envfree functions + function mathWrapper.SECONDS_PER_YEAR() external returns (uint256) envfree; +} + + +//////////////////////////////////////////////////////////////////////////// +// DEFINITIONS // +//////////////////////////////////////////////////////////////////////////// + +definition emptyAsset(uint256 assetId) returns bool = + hub._assets[assetId].addedShares == 0 && + hub._assets[assetId].liquidity == 0 && + hub._assets[assetId].addedShares == 0 && + hub._assets[assetId].deficitRay == 0 && + hub._assets[assetId].swept == 0 && + hub._assets[assetId].premiumShares == 0 && + hub._assets[assetId].premiumOffsetRay == 0 && + hub._assets[assetId].drawnShares == 0 && + hub._assets[assetId].drawnIndex == 0 && + hub._assets[assetId].drawnRate == 0 && + hub._assets[assetId].lastUpdateTimestamp == 0 && + ( forall address spoke. + hub._spokes[assetId][spoke].addedShares == 0 && + hub._spokes[assetId][spoke].drawnShares == 0 && + hub._spokes[assetId][spoke].premiumShares == 0 && + hub._spokes[assetId][spoke].premiumOffsetRay == 0 + ) && + hub._assets[assetId].underlying == 0; + + + +//////////////////////////////////////////////////////////////////////////// +// RULES // +//////////////////////////////////////////////////////////////////////////// + +/** + * @title Two invocations of accrue() at the same block result in a state exactly the same as the first execution + * @link_property accrue integrity + */ +rule runningTwiceIsEquivalentToOne() { + env e; + uint256 assetId; + accrueInterest(e, assetId); + storage afterOne = lastStorage; + accrueInterest(e, assetId); + assert lastStorage == afterOne; +} + +/** + * @title Once baseDebtIndex is set it is at least RAY + * @notice Proved also in invariant baseDebtIndexMin on all Hub functions + * @link_property accrue integrity + */ +rule baseDebtIndexMin_accrue() { + env e; + uint256 assetId; + require hub._assets[assetId].drawnIndex == 0 || hub._assets[assetId].drawnIndex >= RAY; + + accrueInterest(e, assetId); + assert hub._assets[assetId].drawnIndex == 0 || hub._assets[assetId].drawnIndex >= RAY; +} + +/** + * @title lastUpdateTimestamp is updated to the current block timestamp + * @link_property accrue integrity + */ +rule lastUpdateTimestamp_updatedToCurrentBlockTimestamp() { + env e; + uint256 assetId; + require hub._assets[assetId].lastUpdateTimestamp <= e.block.timestamp; + accrueInterest(e, assetId); + assert hub._assets[assetId].lastUpdateTimestamp == e.block.timestamp; +} + + +/** + * @title When accrue is called, no change to other fields beside lastUpdateTimestamp, drawnIndex and realizedFees + * @link_property accrue integrity + */ +rule noChangeToOtherFields_accrue(uint256 assetId) { + env e; + storage beforeStorage = lastStorage; + uint256 beforeTimestamp = hub._assets[assetId].lastUpdateTimestamp; + uint256 beforeIndex = hub._assets[assetId].drawnIndex; + uint256 beforeRealizedFees = hub._assets[assetId].realizedFees; + + accrueInterest(e, assetId); + havoc hub._assets[assetId].lastUpdateTimestamp; + havoc hub._assets[assetId].drawnIndex; + havoc hub._assets[assetId].realizedFees; + require hub._assets[assetId].lastUpdateTimestamp == beforeTimestamp; + require hub._assets[assetId].drawnIndex == beforeIndex; + require hub._assets[assetId].realizedFees == beforeRealizedFees; + assert lastStorage == beforeStorage; +} + +/** + * @title BaseDebtIndex is increasing on block change when baseRate is at least SECONDS_PER_YEAR and index is set + * @assumption baseRate is at least SECONDS_PER_YEAR + * @link_property accrue integrity + */ +rule baseDebtIndex_increasing(uint256 assetId) { + // Proved in invariant baseDebtIndexMin and baseDebtIndexMin_accrue + require hub._assets[assetId].drawnIndex >= RAY; + + uint256 before = hub._assets[assetId].drawnIndex; + + env e; + require e.block.timestamp > hub._assets[assetId].lastUpdateTimestamp && e.block.timestamp <= max_uint40; + mathint baseShareAndPremium = hub._assets[assetId].drawnShares + hub._assets[assetId].premiumShares; + + accrueInterest(e, assetId); + + assert hub._assets[assetId].drawnIndex >= before; + // If there is debt then the drawnIndex should not increase + assert (hub._assets[assetId].drawnRate >= mathWrapper.SECONDS_PER_YEAR() + // Debt is not only the unpaid non interest bearing premium debt + && baseShareAndPremium != 0) => + hub._assets[assetId].drawnIndex > before; + satisfy hub._assets[assetId].drawnRate == mathWrapper.SECONDS_PER_YEAR(); +} + +/** + * @title Prove premiumOffsetRay is always less than or equal to premiumShares * drawnIndex + * @notice This is important to avoid revert on accrue + * @link_property accrue integrity + */ +rule premiumOffset_Integrity_accrue(uint256 assetId, address spokeId) { + env e; + require hub._assets[assetId].lastUpdateTimestamp <= e.block.timestamp; + + // requireInvariant baseDebtIndexMin(assetId); + require hub._assets[assetId].drawnIndex == 0 || hub._assets[assetId].drawnIndex >= RAY; + + require hub._assets[assetId].premiumShares * hub._assets[assetId].drawnIndex >= hub._assets[assetId].premiumOffsetRay && + hub._spokes[assetId][spokeId].premiumShares * hub._assets[assetId].drawnIndex >= hub._spokes[assetId][spokeId].premiumOffsetRay; + + accrueInterest(e, assetId); + + assert hub._assets[assetId].premiumShares * hub._assets[assetId].drawnIndex >= hub._assets[assetId].premiumOffsetRay && + hub._spokes[assetId][spokeId].premiumShares * hub._assets[assetId].drawnIndex >= hub._spokes[assetId][spokeId].premiumOffsetRay; +} + +/** + * @title View functions are isomorphic to accrue, they return the same value if accrue was called or not + * @link_property view functions integrity + */ +rule viewFunctionsIntegrity(uint256 assetId, method f) filtered { f-> f.isView && + f.selector != sig:authority().selector && + f.selector != sig:isConsumingScheduledOp().selector && + f.selector != sig:isSpokeListed(uint256,address).selector && + // returns a struct + f.selector != sig:getAsset(uint256).selector && + f.selector != sig:getAssetConfig(uint256).selector && + f.selector != sig:getSpoke(uint256,address).selector && + f.selector != sig:getSpokeConfig(uint256,address).selector && + f.selector != sig:getSpokeAddress(uint256,uint256).selector && + // harness functions + f.selector != sig:toSharesDown(uint256,uint256,uint256).selector && + f.selector != sig:toAssetsDown(uint256,uint256,uint256).selector && + f.selector != sig:toSharesUp(uint256,uint256,uint256).selector && + f.selector != sig:toAssetsUp(uint256,uint256,uint256).selector && + f.selector != sig:getUnrealizedFees(uint256).selector && + f.selector != sig:MAX_ALLOWED_UNDERLYING_DECIMALS().selector && + f.selector != sig:MAX_ALLOWED_SPOKE_CAP().selector && + f.selector != sig:MAX_RISK_PREMIUM_THRESHOLD().selector && + f.selector != sig:getAssetUnderlyingAndDecimals(uint256).selector + } +{ + env e; + calldataarg args; + + // lastUpdateTimestamp cannot be in the future, prove... + require hub._assets[assetId].lastUpdateTimestamp <= e.block.timestamp; + + // requireInvariant baseDebtIndexMin(assetId); + require hub._assets[assetId].drawnIndex == 0 || hub._assets[assetId].drawnIndex >= RAY; + + mathint ret_withAccrue = callViewFunction(f, e, args); + + // Accrue before calling the view function + accrueInterest(e, assetId); + + mathint ret_withoutAccrue = callViewFunction(f, e, args); + assert ret_withAccrue == ret_withoutAccrue; +} + + +/** + * @title View functions revert under the same state if accrue is called or not called before the view function is called + * @link_property view functions integrity + */ +rule viewFunctionsRevertIntegrity(uint256 assetId, method f) filtered { f-> f.isView && + f.selector != sig:authority().selector && + f.selector != sig:isConsumingScheduledOp().selector && + f.selector != sig:isSpokeListed(uint256,address).selector && + // returns a struct + f.selector != sig:getAsset(uint256).selector && + f.selector != sig:getAssetConfig(uint256).selector && + f.selector != sig:getSpoke(uint256,address).selector && + f.selector != sig:getSpokeConfig(uint256,address).selector && + f.selector != sig:getSpokeAddress(uint256,uint256).selector && + // harness functions + f.selector != sig:toSharesDown(uint256,uint256,uint256).selector && + f.selector != sig:toAssetsDown(uint256,uint256,uint256).selector && + f.selector != sig:toSharesUp(uint256,uint256,uint256).selector && + f.selector != sig:toAssetsUp(uint256,uint256,uint256).selector && + f.selector != sig:getUnrealizedFees(uint256).selector && + f.selector != sig:MAX_ALLOWED_UNDERLYING_DECIMALS().selector && + f.selector != sig:MAX_ALLOWED_SPOKE_CAP().selector && + f.selector != sig:MAX_RISK_PREMIUM_THRESHOLD().selector && + f.selector != sig:getAssetUnderlyingAndDecimals(uint256).selector + } +{ + env e; + calldataarg args; + + // lastUpdateTimestamp cannot be in the future, prove... + require hub._assets[assetId].lastUpdateTimestamp <= e.block.timestamp; + + // requireInvariant baseDebtIndexMin(assetId); + require hub._assets[assetId].drawnIndex == 0 || hub._assets[assetId].drawnIndex >= RAY; + + f(e, args); + + // Accrue before calling the view function + accrueInterest(e, assetId); + f@withrevert(e, args); + assert !lastReverted; +} + + +//////////////////////////////////////////////////////////////////////////// +// HELPER FUNCTIONS // +//////////////////////////////////////////////////////////////////////////// + +/** + * @notice Helper function for calling view functions and fetching the return value as mathint + */ +function callViewFunction(method f, env e, calldataarg args) returns mathint { + if (f.selector == sig:getAssetCount().selector) { + return getAssetCount(e, args); + } + else if (f.selector == sig:getSpokeCount(uint256).selector) { + return getSpokeCount(e, args); + } + else if (f.selector == sig:previewAddByAssets(uint256,uint256).selector) { + return previewAddByAssets(e, args); + } + else if (f.selector == sig:previewAddByShares(uint256,uint256).selector) { + return previewAddByShares(e, args); + } + else if (f.selector == sig:previewRemoveByAssets(uint256,uint256).selector) { + return previewRemoveByAssets(e, args); + } + else if (f.selector == sig:previewRemoveByShares(uint256,uint256).selector) { + return previewRemoveByShares(e, args); + } + else if (f.selector == sig:previewDrawByAssets(uint256,uint256).selector) { + return previewDrawByAssets(e, args); + } + else if (f.selector == sig:previewDrawByShares(uint256,uint256).selector) { + return previewDrawByShares(e, args); + } + else if (f.selector == sig:previewRestoreByAssets(uint256,uint256).selector) { + return previewRestoreByAssets(e, args); + } + else if (f.selector == sig:previewRestoreByShares(uint256,uint256).selector) { + return previewRestoreByShares(e, args); + } + else if (f.selector == sig:getAssetDrawnIndex(uint256).selector) { + return getAssetDrawnIndex(e, args); + } + else if (f.selector == sig:getAssetOwed(uint256).selector) { + uint256 a; uint256 b; (a, b) = getAssetOwed(e, args); return a + b; + } + else if (f.selector == sig:getAssetTotalOwed(uint256).selector) { + return getAssetTotalOwed(e, args); + } + else if (f.selector == sig:getSpokeOwed(uint256,address).selector) { + uint256 a; uint256 b; (a, b) = getSpokeOwed(e, args); return a + b; + } + else if (f.selector == sig:getSpokeTotalOwed(uint256,address).selector) { + return getSpokeTotalOwed(e, args); + } + else if (f.selector == sig:getAssetDrawnRate(uint256).selector) { + return getAssetDrawnRate(e, args); + } + else if (f.selector == sig:getAddedAssets(uint256).selector) { + return getAddedAssets(e, args); + } + else if (f.selector == sig:getAddedShares(uint256).selector) { + return getAddedShares(e, args); + } + else if (f.selector == sig:getSpokeAddedAssets(uint256,address).selector) { + return getSpokeAddedAssets(e, args); + } + else if (f.selector == sig:getSpokeAddedShares(uint256,address).selector) { + return getSpokeAddedShares(e, args); + } + else if (f.selector == sig:getAssetDrawnShares(uint256).selector) { + return getAssetDrawnShares(e, args); + } + else if (f.selector == sig:getAssetPremiumData(uint256).selector) { + uint256 a; int256 b; + (a, b) = getAssetPremiumData(e, args); + return a + to_mathint(b); + } + else if (f.selector == sig:getSpokePremiumData(uint256,address).selector) { + uint256 a; int256 b; + (a, b) = getSpokePremiumData(e, args); + return a + to_mathint(b); + } + else if (f.selector == sig:getAssetPremiumRay(uint256).selector) { + return getAssetPremiumRay(e, args); + } + else if (f.selector == sig:getSpokePremiumRay(uint256,address).selector) { + return getSpokePremiumRay(e, args); + } + else if (f.selector == sig:getSpokeDrawnShares(uint256,address).selector) { + return getSpokeDrawnShares(e, args); + } + else if (f.selector == sig:MIN_ALLOWED_UNDERLYING_DECIMALS().selector) { + return MIN_ALLOWED_UNDERLYING_DECIMALS(e, args); + } + else if (f.selector == sig:getAssetDeficitRay(uint256).selector) { + return getAssetDeficitRay(e, args); + } + else if (f.selector == sig:getAssetLiquidity(uint256).selector) { + return getAssetLiquidity@withrevert(e, args); + + } + else if (f.selector == sig:getAssetSwept(uint256).selector) { + return getAssetSwept(e, args); + } + else if (f.selector == sig:getSpokeDeficitRay(uint256,address).selector) { + return getSpokeDeficitRay(e, args); + } + else if (f.selector == sig:getAssetAccruedFees(uint256).selector) { + return getAssetAccruedFees(e, args); + } + else if (f.selector == sig:isUnderlyingListed(address).selector) { + return isUnderlyingListed(e, args) ? 1 : 0; + } + else if (f.selector == sig:getAssetId(address).selector) { + return getAssetId(e, args); + } + else + { + assert false, "unknown view function"; + return 0; + } + +} diff --git a/certora/spec/HubAccrueSupplyRate.spec b/certora/spec/HubAccrueSupplyRate.spec new file mode 100644 index 000000000..03f69b669 --- /dev/null +++ b/certora/spec/HubAccrueSupplyRate.spec @@ -0,0 +1,275 @@ +/** + * @title Hub Accrue Supply Rate Specification + * @notice Prove that accrue cannot decrease the share rate + * @dev Assets / shares is increasing over time + * @safe_assumption getDrawnIndex is the same value in the same block timestamp, rule runningTwiceIsEquivalentToOne + */ + +import "./HubBase.spec"; + +using HubHarness as hub; + + +//////////////////////////////////////////////////////////////////////////// +// METHODS // +//////////////////////////////////////////////////////////////////////////// + +methods { + function AssetLogic.getDrawnIndex(IHub.Asset storage asset) internal returns (uint256) with (env e) => symbolicDrawnIndex(e.block.timestamp); +} + + +//////////////////////////////////////////////////////////////////////////// +// GHOST VARIABLES // +//////////////////////////////////////////////////////////////////////////// + +// Symbolic representation of drawnIndex that is a function of the block timestamp. +ghost symbolicDrawnIndex(uint256) returns uint256; + +//////////////////////////////////////////////////////////////////////////// +// RULES // +//////////////////////////////////////////////////////////////////////////// + +/** + * @title Prove that accrue cannot decrease the share rate + * @notice Given e1, a timestamp last accrue, we prove that the share rate is the same or increasing at e2 + * @dev We prove this for the maximum value of getUnrealizedFees, as proved in HubAccrueIntegrityUnrealizedFee.spec + * Therefore, it holds for any smaller value of getUnrealizedFees, as shares_e2 will be smaller + * @link_property share rate integrity + */ +rule accrueSupplyRate(uint256 assetId) { + env e1; env e2; + uint256 oneM = 1000000; + require e1.block.timestamp < e2.block.timestamp; + + // e1 is the last accrued timestamp + require hub._assets[assetId].lastUpdateTimestamp != 0 && hub._assets[assetId].lastUpdateTimestamp == e1.block.timestamp; + require hub._assets[assetId].liquidityFee <= PERCENTAGE_FACTOR, "invariant liquidityFee_upper_bound"; + + // Correlate the drawn index with the symbolic one, assume increasing and min value as proved in + // HubAccrueIntegrityDrawnIndex.spec + require hub._assets[assetId].drawnIndex == symbolicDrawnIndex(e1.block.timestamp); + // Based on rule drawnIndex_increasing(assetId); + require symbolicDrawnIndex(e1.block.timestamp) <= symbolicDrawnIndex(e2.block.timestamp); + // Based on requireInvariant baseDebtIndexMin(assetId); + require symbolicDrawnIndex(e1.block.timestamp) >= RAY; + + mathint assets_e1 = getAddedAssets(e1, assetId); + mathint shares_e1 = hub._assets[assetId].addedShares; + // requireInvariant totalAssetsVsShares(assetId,e); + require assets_e1 >= shares_e1; + + // Accrue interest + accrueInterest(e2, assetId); + mathint assets_e2 = getAddedAssets(e2, assetId); + mathint shares_e2 = hub._assets[assetId].addedShares; + + // Verify the assumption that total added assets is always greater than or equal to added shares + assert assets_e2 >= shares_e2; + + assert (assets_e2 + oneM) * (shares_e1 + oneM) >= (assets_e1 + oneM) * (shares_e2 + oneM); + satisfy (assets_e2 + oneM) * (shares_e1 + oneM) > (assets_e1 + oneM) * (shares_e2 + oneM); +} + +/** + * @title Check assumption that total added shares matches hub storage + */ +rule checkAssumptionTotalAddedShares(uint256 assetId, env e) { + assert hub._assets[assetId].addedShares == getAddedShares(e, assetId); +} + +//////////////////////////////////////////////////////////////////////////// +// HELPER FUNCTIONS // +//////////////////////////////////////////////////////////////////////////// + +function setup_three_timestamps(uint256 assetId, env e1, env e2, env e3) { + require e1.block.timestamp < e2.block.timestamp && e2.block.timestamp < e3.block.timestamp; + + require hub._assets[assetId].lastUpdateTimestamp != 0 && hub._assets[assetId].lastUpdateTimestamp == e1.block.timestamp; + // Correlate the drawn index with the symbolic one, assume increasing and min value as proved in + // HubAccrueIntegrityDrawnIndex.spec + require hub._assets[assetId].drawnIndex == symbolicDrawnIndex(e1.block.timestamp); + // Based on rule drawnIndex_increasing(assetId); + require symbolicDrawnIndex(e1.block.timestamp) <= symbolicDrawnIndex(e2.block.timestamp); + require symbolicDrawnIndex(e2.block.timestamp) <= symbolicDrawnIndex(e3.block.timestamp); + // Based on requireInvariant baseDebtIndexMin(assetId); + require symbolicDrawnIndex(e1.block.timestamp) >= RAY; + require hub._assets[assetId].liquidityFee <= PERCENTAGE_FACTOR, "invariant liquidityFee_upper_bound"; +} + + +/** + * @title Share rate is monotonic over time without accrue + * @link_property share rate integrity + */ +rule shareRate_withoutAccrue_time_monotonic(uint256 assetId) { + env e1; env e2; env e3; + setup_three_timestamps(assetId, e1, e2, e3); + require hub._assets[assetId].liquidityFee <= PERCENTAGE_FACTOR, "invariant liquidityFee_upper_bound"; + + mathint assets_e1 = getAddedAssets(e1, assetId); + // Proved in checkAssumptionTotalAddedShares that totalAddedShares is always the hub._assets[assetId].addedShares + mathint shares = hub._assets[assetId].addedShares; + // requireInvariant totalAssetsVsShares(assetId,e); + require assets_e1 >= shares; + + // Get the fee shares and asset at e2 + mathint assets_e2 = getAddedAssets(e2, assetId); + + // Get the fee shares and asset at e3 + mathint assets_e3 = getAddedAssets(e3, assetId); + + // We prove this: + // assert (assets_e3 + oneM) * (shares + oneM) >= (assets_e2 + oneM) * (shares + oneM); + // by proving: + assert assets_e3 >= assets_e2; +} + + +/** + * @title Preview remove by shares is monotonic over time without accrue + * @link_property view function integrity over time + */ +rule previewRemoveByShares_withoutAccrue_time_monotonic(uint256 assetId, uint256 shares) { + env e1; env e2; env e3; + setup_three_timestamps(assetId, e1, e2, e3); + + mathint assets_e1 = previewRemoveByShares(e1, assetId, shares); + mathint assets_e2 = previewRemoveByShares(e2, assetId, shares); + mathint assets_e3 = previewRemoveByShares(e3, assetId, shares); + + assert assets_e3 >= assets_e2; + assert assets_e2 >= assets_e1; +} + + +/** + * @title Preview add by assets is monotonic over time without accrue + * @link_property view function integrity over time + */ +rule previewAddByAssets_withoutAccrue_time_monotonic(uint256 assetId, uint256 assets) { + env e1; env e2; env e3; + setup_three_timestamps(assetId, e1, e2, e3); + + mathint shares_e1 = previewAddByAssets(e1, assetId, assets); + mathint shares_e2 = previewAddByAssets(e2, assetId, assets); + mathint shares_e3 = previewAddByAssets(e3, assetId, assets); + + assert shares_e3 <= shares_e2 && shares_e2 <= shares_e1; +} + + +/** + * @title Preview add by shares is monotonic over time without accrue + * @notice Due to timeouts Prove that previewAddByShares is monotonic over time without accrue for the case where liquidityFee is 0, PERCENTAGE_FACTOR or PERCENTAGE_FACTOR / 2 + * @link_property view function integrity over time + */ +rule previewAddByShares_withoutAccrue_time_monotonic_part1(uint256 assetId, uint256 shares) { + env e1; env e2; env e3; + setup_three_timestamps(assetId, e1, e2, e3); + uint256 liquidityFee = hub._assets[assetId].liquidityFee; + require liquidityFee == PERCENTAGE_FACTOR + || liquidityFee == 0 ||liquidityFee == PERCENTAGE_FACTOR / 2; + + mathint assets_e2 = previewAddByShares(e2, assetId, shares); + mathint assets_e3 = previewAddByShares(e3, assetId, shares); + + assert assets_e3 >= assets_e2; + +} + +/** + * @title Preview add by shares is monotonic over time without accrue + * @link_property view function integrity over time + */ +rule previewAddByShares_withoutAccrue_time_monotonic_part2(uint256 assetId, uint256 shares) { + env e1; env e2; env e3; + setup_three_timestamps(assetId, e1, e2, e3); + + mathint assets_e1 = previewAddByShares(e1, assetId, shares); + mathint assets_e2 = previewAddByShares(e2, assetId, shares); + + assert assets_e2 >= assets_e1; +} + + +/** + * @title Preview remove by assets is monotonic over time without accrue + * @link_property view function integrity over time + */ +rule previewRemoveByAssets_withoutAccrue_time_monotonic(uint256 assetId, uint256 assets) { + env e1; env e2; env e3; + setup_three_timestamps(assetId, e1, e2, e3); + + mathint shares_e1 = previewRemoveByAssets(e1, assetId, assets); + mathint shares_e2 = previewRemoveByAssets(e2, assetId, assets); + mathint shares_e3 = previewRemoveByAssets(e3, assetId, assets); + + assert shares_e3 <= shares_e2 && shares_e2 <= shares_e1; +} + + + +/** + * @title Preview draw by assets is monotonic over time without accrue + * @link_property view function integrity over time + */ +rule previewDrawByAssets_withoutAccrue_time_monotonic(uint256 assetId, uint256 assets) { + env e1; env e2; env e3; + setup_three_timestamps(assetId, e1, e2, e3); + + mathint shares_e1 = previewDrawByAssets(e1, assetId, assets); + mathint shares_e2 = previewDrawByAssets(e2, assetId, assets); + mathint shares_e3 = previewDrawByAssets(e3, assetId, assets); + + assert shares_e3 <= shares_e2 && shares_e2 <= shares_e1; +} + + +/** + * @title Preview draw by shares is monotonic over time without accrue + * @link_property view function integrity over time + */ +rule previewDrawByShares_withoutAccrue_time_monotonic(uint256 assetId, uint256 shares) { + env e1; env e2; env e3; + setup_three_timestamps(assetId, e1, e2, e3); + + mathint assets_e1 = previewDrawByShares(e1, assetId, shares); + mathint assets_e2 = previewDrawByShares(e2, assetId, shares); + mathint assets_e3 = previewDrawByShares(e3, assetId, shares); + + assert assets_e3 >= assets_e2 && assets_e2 >= assets_e1; +} + + + +/** + * @title Preview restore by assets is monotonic over time without accrue + * @link_property view function integrity over time + */ +rule previewRestoreByAssets_withoutAccrue_time_monotonic(uint256 assetId, uint256 assets) { + env e1; env e2; env e3; + setup_three_timestamps(assetId, e1, e2, e3); + + mathint shares_e1 = previewRestoreByAssets(e1, assetId, assets); + mathint shares_e2 = previewRestoreByAssets(e2, assetId, assets); + mathint shares_e3 = previewRestoreByAssets(e3, assetId, assets); + + assert shares_e3 <= shares_e2 && shares_e2 <= shares_e1; +} + + +/** + * @title Preview restore by shares is monotonic over time without accrue + * @link_property view function integrity over time +*/ +rule previewRestoreByShares_withoutAccrue_time_monotonic(uint256 assetId, uint256 shares) { + env e1; env e2; env e3; + setup_three_timestamps(assetId, e1, e2, e3); + + mathint assets_e1 = previewRestoreByShares(e1, assetId, shares); + mathint assets_e2 = previewRestoreByShares(e2, assetId, shares); + mathint assets_e3 = previewRestoreByShares(e3, assetId, shares); + + assert assets_e3 >= assets_e2 && assets_e2 >= assets_e1; +} \ No newline at end of file diff --git a/certora/spec/HubAccrueUnrealizedFee.spec b/certora/spec/HubAccrueUnrealizedFee.spec new file mode 100644 index 000000000..a783d9ae7 --- /dev/null +++ b/certora/spec/HubAccrueUnrealizedFee.spec @@ -0,0 +1,87 @@ +/** + * @title Hub Accrue Unrealized Fee Specification + * @notice Prove unit test properties of getUnrealizedFees + * @safe_assumption getDrawnIndex is the same value in the same block timestamp, rule runningTwiceIsEquivalentToOne + */ + +import "./HubBase.spec"; + +using HubHarness as hub; + + +//////////////////////////////////////////////////////////////////////////// +// METHODS // +//////////////////////////////////////////////////////////////////////////// + +methods { + function AssetLogic.getDrawnIndex(IHub.Asset storage asset) internal returns (uint256) with (env e) => symbolicDrawnIndex(e.block.timestamp); +} + + +//////////////////////////////////////////////////////////////////////////// +// GHOST VARIABLES // +//////////////////////////////////////////////////////////////////////////// + +// Symbolic representation of drawnIndex that is a function of the block timestamp. +ghost symbolicDrawnIndex(uint256) returns uint256; + +//////////////////////////////////////////////////////////////////////////// +// RULES // +//////////////////////////////////////////////////////////////////////////// + +/** + * @title Fee amount increase in accrue is equal to the unrealized fee at this timestamp + * @link_property fee amount state change during accrue + */ +rule feeAmountIncrease(uint256 assetId) { + env e1; env e2; + + require e1.block.timestamp < e2.block.timestamp; + + // Assume accrue was called at e1.block.timestamp + require hub._assets[assetId].lastUpdateTimestamp != 0 && hub._assets[assetId].lastUpdateTimestamp == e1.block.timestamp; + require hub._assets[assetId].drawnIndex == symbolicDrawnIndex(e1.block.timestamp); + require symbolicDrawnIndex(e1.block.timestamp) <= symbolicDrawnIndex(e2.block.timestamp); + require symbolicDrawnIndex(e1.block.timestamp) >= RAY; + uint256 feeAssetsBefore = hub._assets[assetId].realizedFees; + uint256 feeAssets = getUnrealizedFees(e2, assetId); + accrueInterest(e2, assetId); + assert hub._assets[assetId].realizedFees == feeAssetsBefore + feeAssets; +} + +/** + * @title Prove that the maximum value of getUnrealizedFees is at 100% liquidityFee + * @link_property getUnrealizedFees integrity + */ +rule maxgetUnrealizedFees(uint256 assetId) { + env e1; env e2; + require e1.block.timestamp < e2.block.timestamp; + require hub._assets[assetId].lastUpdateTimestamp != 0 && hub._assets[assetId].lastUpdateTimestamp == e1.block.timestamp; + + require hub._assets[assetId].drawnIndex == symbolicDrawnIndex(e1.block.timestamp); + require symbolicDrawnIndex(e1.block.timestamp) <= symbolicDrawnIndex(e2.block.timestamp); + require symbolicDrawnIndex(e1.block.timestamp) >= RAY; + assert getUnrealizedFees(e1, assetId) == 0; + + storage init_state = lastStorage; + require hub._assets[assetId].liquidityFee == PERCENTAGE_FACTOR; + uint256 feesAtMax = getUnrealizedFees(e2, assetId); + + // Assume any value that can be set in updateAssetConfig + // Must be called at e1 as accrue is happening in updateAssetConfig + IHub.AssetConfig config; + bytes irData; + updateAssetConfig(e1, assetId, config, irData) at init_state; + assert getUnrealizedFees(e2, assetId) <= feesAtMax; +} + +/** + * @title Prove that when the lastUpdateTimestamp is the same as the block timestamp, the unrealized fees are 0 + * @link_property getUnrealizedFees integrity + */ +rule lastUpdateTimestampSameAsBlockTimestamp(uint256 assetId) { + env e; + require hub._assets[assetId].lastUpdateTimestamp != 0 && hub._assets[assetId].lastUpdateTimestamp == e.block.timestamp; + require hub._assets[assetId].drawnIndex == symbolicDrawnIndex(e.block.timestamp); + assert getUnrealizedFees(e, assetId) == 0; +} \ No newline at end of file diff --git a/certora/spec/HubAdditivity.spec b/certora/spec/HubAdditivity.spec new file mode 100644 index 000000000..46fe3eee0 --- /dev/null +++ b/certora/spec/HubAdditivity.spec @@ -0,0 +1,195 @@ +/** + * @title Hub Additivity Specification + * @notice Verify the additivity of the operations: add, remove, draw, restore, reportDeficit, eliminateDeficit + * @dev For each operation, we verify that splitting an operation to two operations is less beneficial to the user than doing it in one step. + * + + * + * To run this spec file: + * certoraRun certora/conf/HubAdditivity.conf + */ + +import "./symbolicRepresentation/ERC20s_CVL.spec"; +import "./symbolicRepresentation/Math_CVL.spec"; +import "./hub.spec"; + + + +//////////////////////////////////////////////////////////////////////////// +// RULES // +//////////////////////////////////////////////////////////////////////////// + +/** + * @title Adding in two steps is less beneficial to the user than doing it in one step + * @link_property additivity of the operations + */ +rule addAdditivity(uint256 assetId, uint256 amountX, uint256 amountY, address from) { + env e; + address spoke = e.msg.sender; + setup_additivity(assetId,e); + storage init = lastStorage; + + add(e, assetId, amountX); + add(e, assetId, amountY); + uint256 afterTwoSteps = getSpokeAddedShares(e, assetId, spoke); + + //expecting the code to enforce that amountX+amountY can not overflow + add(e, assetId, assert_uint256(amountX + amountY)) at init; + uint256 afterOneStep = getSpokeAddedShares(e, assetId, spoke); + + //rounding should be in favor of the house + assert afterOneStep >= afterTwoSteps; + satisfy afterOneStep > afterTwoSteps; +} + +/** +* @title Removing in two steps is less beneficial to the user than doing it in one step +* @link_property additivity of the operations +**/ +rule removeAdditivity(uint256 assetId, uint256 amountX, uint256 amountY, address from) { + env e; + address spoke = e.msg.sender; + setup_additivity(assetId,e); + storage init = lastStorage; + + remove(e, assetId, amountX, from); + remove(e, assetId, amountY, from); + uint256 afterTwoSteps = getSpokeAddedShares(e, assetId, spoke); + + //expecting the code to enforce that amountX+amountY can not overflow + remove(e, assetId, assert_uint256(amountX + amountY), from)at init; + uint256 afterOneStep = getSpokeAddedShares(e, assetId, spoke); + + //rounding should be in favor of the house + assert afterOneStep >= afterTwoSteps; +} + + +/** +* @title Drawing in two steps is less beneficial to the user than doing it in one step +* @link_property additivity of the operations +**/ +rule drawAdditivity(uint256 assetId, uint256 amountX, uint256 amountY, address from) { + env e; + address spoke = e.msg.sender; + setup_additivity(assetId,e); + storage init = lastStorage; + + draw(e, assetId, amountX, from); + draw(e, assetId, amountY, from); + uint256 afterTwoSteps = getSpokeDrawnShares(e, assetId, spoke) ; + //expecting the code to enforce that amountX+amountY can not overflow + draw(e, assetId, assert_uint256(amountX + amountY), from)at init; + uint256 afterOneStep = getSpokeDrawnShares(e, assetId, spoke); + + //rounding should be in favor of the house + assert afterOneStep <= afterTwoSteps; + satisfy afterOneStep < afterTwoSteps; +} + +/** +@title Restoring in two steps is less beneficial to the user than doing it in one step +* @link_property additivity of the operations +**/ +rule restoreAdditivity(uint256 assetId, uint256 amountX, uint256 amountY, address from) { + env e; + address spoke = e.msg.sender; + setup_additivity(assetId,e); + storage init = lastStorage; + + IHubBase.PremiumDelta premiumDeltaX; + IHubBase.PremiumDelta premiumDeltaY; + IHubBase.PremiumDelta premiumDeltaXY; + require premiumDeltaXY.sharesDelta == premiumDeltaX.sharesDelta + premiumDeltaY.sharesDelta; + require premiumDeltaXY.offsetRayDelta == premiumDeltaX.offsetRayDelta + premiumDeltaY.offsetRayDelta; + require premiumDeltaXY.restoredPremiumRay == premiumDeltaX.restoredPremiumRay + premiumDeltaY.restoredPremiumRay; + + restore(e, assetId, amountX, premiumDeltaX); + restore(e, assetId, amountY, premiumDeltaY); + uint256 drawnSharesAfterTwoSteps = hub._spokes[assetId][spoke].drawnShares; + uint256 premiumSharesAfterTwoSteps = hub._spokes[assetId][spoke].premiumShares; + int200 premiumOffsetRayAfterTwoSteps = hub._spokes[assetId][spoke].premiumOffsetRay; + + //expecting the code to enforce that amountX+amountY can not overflow + restore(e, assetId, assert_uint256(amountX + amountY), premiumDeltaXY) at init; + + uint256 drawnSharesAfterOneStep = hub._spokes[assetId][spoke].drawnShares; + uint256 premiumSharesAfterOneStep = hub._spokes[assetId][spoke].premiumShares; + int200 premiumOffsetRayAfterOneStep = hub._spokes[assetId][spoke].premiumOffsetRay; + + assert drawnSharesAfterOneStep <= drawnSharesAfterTwoSteps; + assert premiumSharesAfterOneStep == premiumSharesAfterTwoSteps; + assert premiumOffsetRayAfterOneStep == premiumOffsetRayAfterTwoSteps; + satisfy drawnSharesAfterOneStep < drawnSharesAfterTwoSteps; +} + +/** +@title Reporting deficit in two steps is less beneficial to the user than doing it in one step +* @link_property additivity of the operations +**/ +rule reportDeficitAdditivity(uint256 assetId, uint256 amountX, uint256 amountY) { + env e; + address spoke = e.msg.sender; + setup_additivity(assetId,e); + storage init = lastStorage; + IHubBase.PremiumDelta premiumDeltaX; + IHubBase.PremiumDelta premiumDeltaY; + IHubBase.PremiumDelta premiumDeltaXY; + + require premiumDeltaXY.sharesDelta == premiumDeltaX.sharesDelta + premiumDeltaY.sharesDelta; + require premiumDeltaXY.offsetRayDelta == premiumDeltaX.offsetRayDelta + premiumDeltaY.offsetRayDelta; + require premiumDeltaXY.restoredPremiumRay == premiumDeltaX.restoredPremiumRay + premiumDeltaY.restoredPremiumRay; + + + reportDeficit(e, assetId, amountX, premiumDeltaX); + reportDeficit(e, assetId, amountY, premiumDeltaY); + + uint256 drawnSharesAfterTwoSteps = hub._spokes[assetId][spoke].drawnShares; + uint256 premiumSharesAfterTwoSteps = hub._spokes[assetId][spoke].premiumShares; + int200 premiumOffsetRayAfterTwoSteps = hub._spokes[assetId][spoke].premiumOffsetRay; + uint256 deficitRayAfterTwoSteps = hub._spokes[assetId][spoke].deficitRay; + //expecting the code to enforce that amountX+amountY can not overflow + reportDeficit(e, assetId, assert_uint256(amountX + amountY), premiumDeltaXY) at init; + uint256 drawnSharesAfterOneStep = hub._spokes[assetId][spoke].drawnShares; + uint256 premiumSharesAfterOneStep = hub._spokes[assetId][spoke].premiumShares; + int200 premiumOffsetRayAfterOneStep = hub._spokes[assetId][spoke].premiumOffsetRay; + uint256 deficitRayAfterOneStep = hub._spokes[assetId][spoke].deficitRay; + + + assert drawnSharesAfterOneStep <= drawnSharesAfterTwoSteps; + assert premiumSharesAfterOneStep == premiumSharesAfterTwoSteps; + assert premiumOffsetRayAfterOneStep == premiumOffsetRayAfterTwoSteps; + assert deficitRayAfterOneStep >= deficitRayAfterTwoSteps; + + satisfy drawnSharesAfterOneStep < drawnSharesAfterTwoSteps && deficitRayAfterOneStep > deficitRayAfterTwoSteps; +} + +/** +@title Prove that eliminating deficit in two steps is less beneficial to the user than doing it in one step +@notice Can only compare deficit ray as supply shares cause timeouts +* @link_property additivity of the operations +**/ +rule eliminateDeficitAdditivity_DeficitRay(uint256 assetId, uint256 amountX, uint256 amountY, address spoke) { + env e; + + setup_additivity(assetId,e); + storage init = lastStorage; + eliminateDeficit(e, assetId, amountX, spoke); + eliminateDeficit(e, assetId, amountY, spoke); + + uint256 addedSharesAfterTwoSteps = hub._spokes[assetId][e.msg.sender].addedShares; + uint256 deficitRayAfterTwoSteps = hub._spokes[assetId][spoke].deficitRay; + + //expecting the code to enforce that amountX+amountY can not overflow + eliminateDeficit(e, assetId, require_uint256(amountX + amountY), spoke) at init; + uint256 addedSharesAfterOneStep = hub._spokes[assetId][e.msg.sender].addedShares; + uint256 deficitRayAfterOneStep = hub._spokes[assetId][spoke].deficitRay; + + assert deficitRayAfterOneStep == deficitRayAfterTwoSteps; +} + + +function setup_additivity(uint256 assetId, env e) { + //requireInvariant totalAssetsVsShares(assetId,e); + require getAddedAssets(e,assetId) >= getAddedShares(e,assetId); +} diff --git a/certora/spec/HubBase.spec b/certora/spec/HubBase.spec new file mode 100644 index 000000000..7b89c3851 --- /dev/null +++ b/certora/spec/HubBase.spec @@ -0,0 +1,30 @@ + +import "./symbolicRepresentation/ERC20s_CVL.spec"; +import "./symbolicRepresentation/Math_CVL.spec"; +import "./common.spec"; + + +/** +* @title Base definitions used in all of Hub spec files +* @notice safe summarization that are proved in other files +@assumption calculateInterestRate is a pure deterministic function of the input parameters +***/ + +methods { + + function _.calculateInterestRate(uint256 assetId, uint256 liquidity, uint256 drawn, uint256 deficit, uint256 swept) external => interestRateGhost(assetId, liquidity, drawn, deficit, swept) expect uint256; + + // summary proved in libs/Premium.spec + function Premium.calculatePremiumRay( + uint256 premiumShares, + int256 premiumOffsetRay, + uint256 drawnIndex + ) internal returns (uint256)=> calculatePremiumRayCVL(premiumShares, premiumOffsetRay, drawnIndex); + +} + +function calculatePremiumRayCVL(uint256 premiumShares, int256 premiumOffsetRay, uint256 drawnIndex) returns uint256 { + return require_uint256((premiumShares * drawnIndex) - premiumOffsetRay); +} + +ghost interestRateGhost(uint256, uint256, uint256, uint256, uint256) returns uint256; \ No newline at end of file diff --git a/certora/spec/HubIntegrity.spec b/certora/spec/HubIntegrity.spec new file mode 100644 index 000000000..f2f1ac934 --- /dev/null +++ b/certora/spec/HubIntegrity.spec @@ -0,0 +1,409 @@ +/** + * @title Hub Integrity Specification + * @notice Hub verification integrity rules that verify that change is consistent + * @dev Accrue is assumed to be called already + * + * To run this spec file: + * certoraRun certora/conf/HubIntegrity.conf + */ + +import "./symbolicRepresentation/ERC20s_CVL.spec"; +import "./HubValidState.spec"; + +//////////////////////////////////////////////////////////////////////////// +// RULES // +//////////////////////////////////////////////////////////////////////////// + +/** + * @title Add operation increases external balances and increases internal accounting while decreasing from balance + * @link_property Hub integrity + */ +rule nothingForZero_add(uint256 assetId, uint256 amount, address from) { + env e; + address asset = hub._assets[assetId].underlying; + address spoke = e.msg.sender; + uint256 internalBalanceBefore = hub._assets[assetId].liquidity; + uint256 spokeSharesBefore = hub._spokes[assetId][spoke].addedShares; + + uint256 sharesAdded = add(e, assetId, amount); + + assert hub._assets[assetId].liquidity > internalBalanceBefore && hub._spokes[assetId][spoke].addedShares == spokeSharesBefore + sharesAdded; + assert amount > 0; +} + +/** + * @title Remove operation decreases external balances and decreases internal accounting while increasing to balance + * @link_property Hub integrity + */ +rule nothingForZero_remove(uint256 assetId, uint256 amount, address to) { + env e; + address asset = hub._assets[assetId].underlying; + address spoke = e.msg.sender; + uint256 externalBalanceBefore = balanceByToken[asset][hub]; + uint256 toBalanceBefore = balanceByToken[asset][to]; + uint256 spokeSharesBefore = hub._spokes[assetId][spoke].addedShares; + + remove(e, assetId, amount, to); + + assert balanceByToken[asset][hub] < externalBalanceBefore && hub._spokes[assetId][spoke].addedShares < spokeSharesBefore && toBalanceBefore < balanceByToken[asset][to]; + // no fee and no asset lost + assert balanceByToken[asset][hub] + balanceByToken[asset][to] == externalBalanceBefore + toBalanceBefore; + assert amount > 0; +} + +/** + * @title Draw operation increases debt shares and transfers assets to recipient + * @link_property Hub integrity + */ +rule nothingForZero_draw(uint256 assetId, uint256 amount, address to) { + env e; + address asset = hub._assets[assetId].underlying; + address spoke = e.msg.sender; + uint256 drawnSharesBefore = hub._spokes[assetId][spoke].drawnShares; + uint256 externalBalanceBefore = balanceByToken[asset][hub]; + uint256 toBalanceBefore = balanceByToken[asset][to]; + uint256 liquidityBefore = hub._assets[assetId].liquidity; + + draw(e, assetId, amount, to); + + assert hub._spokes[assetId][spoke].drawnShares > drawnSharesBefore && + balanceByToken[asset][hub] < externalBalanceBefore && + balanceByToken[asset][to] > toBalanceBefore && + hub._assets[assetId].liquidity < liquidityBefore && + amount > 0; +} + +/** + * @title Report deficit operation decreases debt shares and increases liquidity + * @link_property Hub integrity + */ +rule nothingForZero_eliminateDeficit(uint256 assetId, uint256 amount, address spoke) { + env e; + requireAllInvariants(assetId, e); + + uint256 senderAddedSharesBefore = hub._spokes[assetId][e.msg.sender].addedShares; + uint256 deficitRayBefore = hub._spokes[assetId][spoke].deficitRay; + + eliminateDeficit(e, assetId, amount, spoke); + + uint256 deficitRayAfter = hub._spokes[assetId][spoke].deficitRay; + assert (senderAddedSharesBefore > hub._spokes[assetId][e.msg.sender].addedShares && + deficitRayBefore > deficitRayAfter); +} + +/** + * @title Sweep operation increases liquidity and decreases swept + * @link_property Hub integrity + */ +rule nothing_for_zero_sweep(uint256 assetId, uint256 amount) { + env e; + requireAllInvariants(assetId, e); + uint256 liquidityBefore = hub._assets[assetId].liquidity; + uint256 sweptBefore = hub._assets[assetId].swept; + sweep(e, assetId, amount); + assert amount > 0; + assert liquidityBefore == hub._assets[assetId].liquidity + amount; + assert sweptBefore == hub._assets[assetId].swept - amount; +} + +/** + * @title Reclaim operation increases liquidity and decreases swept + * @link_property Hub integrity + */ +rule nothing_for_zero_reclaim(uint256 assetId, uint256 amount) { + env e; + requireAllInvariants(assetId, e); + uint256 liquidityBefore = hub._assets[assetId].liquidity; + uint256 sweptBefore = hub._assets[assetId].swept; + reclaim(e, assetId, amount); + assert amount > 0; + assert liquidityBefore == hub._assets[assetId].liquidity - amount; + assert sweptBefore == hub._assets[assetId].swept + amount; +} + +/** + * @title Add operation increases liquidity and decreases spoke added shares + * @link_property Hub integrity + */ +rule add_integrity(uint256 assetId, uint256 amount) { + env e; + requireAllInvariants(assetId, e); + address spoke = e.msg.sender; + + uint256 drawnSharesBefore = hub._spokes[assetId][spoke].drawnShares; + uint256 premiumSharesBefore = hub._spokes[assetId][spoke].premiumShares; + int200 premiumOffsetRayBefore = hub._spokes[assetId][spoke].premiumOffsetRay; + uint256 deficitRayBefore = hub._spokes[assetId][spoke].deficitRay; + uint256 spokeAddedShares_ = hub._spokes[assetId][spoke].addedShares; + uint256 liquidityBefore = hub._assets[assetId].liquidity; + uint256 sharesAddedByPreview = previewAddByAssets(e, assetId, amount); + uint256 sharesAdded = add(e, assetId, amount); + + assert sharesAddedByPreview == sharesAdded; + assert liquidityBefore == hub._assets[assetId].liquidity - amount; + assert spokeAddedShares_ < hub._spokes[assetId][spoke].addedShares; + assert premiumSharesBefore == hub._spokes[assetId][spoke].premiumShares; + assert premiumOffsetRayBefore == hub._spokes[assetId][spoke].premiumOffsetRay; + assert deficitRayBefore == hub._spokes[assetId][spoke].deficitRay; + assert drawnSharesBefore == hub._spokes[assetId][spoke].drawnShares; +} + +/** + * @title Remove operation decreases drawn shares, premium shares, premium offset, deficit ray, spoke added shares, liquidity, external balance, and to balance + * @link_property Hub integrity + */ +rule remove_integrity(uint256 assetId, uint256 amount, address to) { + env e; + requireAllInvariants(assetId, e); + address spoke = e.msg.sender; + address asset = hub._assets[assetId].underlying; + uint256 drawnSharesBefore = hub._spokes[assetId][spoke].drawnShares; + uint256 premiumSharesBefore = hub._spokes[assetId][spoke].premiumShares; + int200 premiumOffsetRayBefore = hub._spokes[assetId][spoke].premiumOffsetRay; + uint256 deficitRayBefore = hub._spokes[assetId][spoke].deficitRay; + uint256 spokeAddedShares_ = hub._spokes[assetId][spoke].addedShares; + uint256 liquidityBefore = hub._assets[assetId].liquidity; + uint256 externalBalanceBefore = balanceByToken[asset][hub]; + uint256 toBalanceBefore = balanceByToken[asset][to]; + + uint256 sharesRemovedByPreview = previewRemoveByAssets(e, assetId, amount); + uint256 sharesRemoved = remove(e, assetId, amount, to); + + assert sharesRemovedByPreview == sharesRemoved; + assert drawnSharesBefore == hub._spokes[assetId][spoke].drawnShares; + assert premiumSharesBefore == hub._spokes[assetId][spoke].premiumShares; + assert premiumOffsetRayBefore == hub._spokes[assetId][spoke].premiumOffsetRay; + assert deficitRayBefore == hub._spokes[assetId][spoke].deficitRay; + assert spokeAddedShares_ > hub._spokes[assetId][spoke].addedShares; + assert liquidityBefore == hub._assets[assetId].liquidity + amount; + assert to != hub => externalBalanceBefore == balanceByToken[asset][hub] + amount; + assert to != hub => toBalanceBefore == balanceByToken[asset][to] - amount; +} + +/** + * @title Draw operation decreases drawn shares, premium ray, spoke added shares, deficit ray, liquidity, external balance, and to balance + * @link_property Hub integrity + */ +rule draw_integrity(uint256 assetId, uint256 amount, address to) { + env e; + requireAllInvariants(assetId, e); + address spoke = e.msg.sender; + address asset = hub._assets[assetId].underlying; + uint256 drawnSharesBefore = hub._spokes[assetId][spoke].drawnShares; + mathint premiumRayBefore = hub._spokes[assetId][spoke].premiumShares * hub._assets[assetId].drawnIndex - hub._spokes[assetId][spoke].premiumOffsetRay; + uint256 spokeAddedShares_ = hub._spokes[assetId][spoke].addedShares; + uint256 deficitRayBefore = hub._spokes[assetId][spoke].deficitRay; + uint256 liquidityBefore = hub._assets[assetId].liquidity; + uint256 externalBalanceBefore = balanceByToken[asset][hub]; + uint256 toBalanceBefore = balanceByToken[asset][to]; + + draw(e, assetId, amount, to); + + assert drawnSharesBefore < hub._spokes[assetId][spoke].drawnShares; + assert premiumRayBefore == hub._spokes[assetId][spoke].premiumShares * hub._assets[assetId].drawnIndex - hub._spokes[assetId][spoke].premiumOffsetRay; + assert spokeAddedShares_ == hub._spokes[assetId][spoke].addedShares; + assert deficitRayBefore == hub._spokes[assetId][spoke].deficitRay; + assert liquidityBefore == hub._assets[assetId].liquidity + amount; + assert to != hub => externalBalanceBefore == balanceByToken[asset][hub] + amount; + assert to != hub => toBalanceBefore == balanceByToken[asset][to] - amount; +} + +/** + * @title Restore operation decreases debt shares, premium ray, deficit ray, spoke added shares, liquidity + * @link_property Hub integrity + */ +rule restore_integrity(uint256 assetId, uint256 drawnAmount, IHubBase.PremiumDelta premiumDelta) { + env e; + requireAllInvariants(assetId, e); + address spoke = e.msg.sender; + uint256 beforeDebt = getSpokeTotalOwed(e, assetId, spoke); + uint256 drawnSharesBefore = hub._spokes[assetId][spoke].drawnShares; + mathint premiumRayBefore = hub._spokes[assetId][spoke].premiumShares * hub._assets[assetId].drawnIndex - hub._spokes[assetId][spoke].premiumOffsetRay; + uint256 deficitRayBefore = hub._spokes[assetId][spoke].deficitRay; + uint256 spokeAddedShares_ = hub._spokes[assetId][spoke].addedShares; + uint256 liquidityBefore = hub._assets[assetId].liquidity; + + restore(e, assetId, drawnAmount, premiumDelta); + + uint256 afterDebt = getSpokeTotalOwed(e, assetId, spoke); + assert beforeDebt >= afterDebt; + assert drawnSharesBefore >= hub._spokes[assetId][spoke].drawnShares; + assert premiumRayBefore >= hub._spokes[assetId][spoke].premiumShares * hub._assets[assetId].drawnIndex - hub._spokes[assetId][spoke].premiumOffsetRay; + assert deficitRayBefore == hub._spokes[assetId][spoke].deficitRay; + assert spokeAddedShares_ == hub._spokes[assetId][spoke].addedShares; + // liquidity can increase by more than the drawn amount due to Premium + assert liquidityBefore <= hub._assets[assetId].liquidity - drawnAmount; +} + +/** + * @title Report deficit operation decreases drawn shares, premium ray, deficit ray, spoke added shares, liquidity + * @link_property Hub integrity + */ +rule reportDeficit_integrity(uint256 assetId, uint256 drawnAmount, IHubBase.PremiumDelta premiumDelta) { + env e; + requireAllInvariants(assetId, e); + address spoke = e.msg.sender; + uint256 drawnSharesBefore = hub._spokes[assetId][spoke].drawnShares; + mathint premiumRayBefore = hub._spokes[assetId][spoke].premiumShares * hub._assets[assetId].drawnIndex - hub._spokes[assetId][spoke].premiumOffsetRay; + uint256 deficitRayBefore = hub._spokes[assetId][spoke].deficitRay; + uint256 spokeAddedShares_ = hub._spokes[assetId][spoke].addedShares; + uint256 liquidityBefore = hub._assets[assetId].liquidity; + + reportDeficit(e, assetId, drawnAmount, premiumDelta); + + assert drawnSharesBefore >= hub._spokes[assetId][spoke].drawnShares; + assert premiumRayBefore >= hub._spokes[assetId][spoke].premiumShares * hub._assets[assetId].drawnIndex - hub._spokes[assetId][spoke].premiumOffsetRay; + assert deficitRayBefore <= hub._spokes[assetId][spoke].deficitRay; + assert spokeAddedShares_ == hub._spokes[assetId][spoke].addedShares; + assert liquidityBefore == hub._assets[assetId].liquidity; +} + +/** + * @title Eliminate deficit operation increases spoke added shares, liquidity, drawn shares, premium shares, premium offset, and deficit ray + * @link_property Hub integrity + */ +rule eliminateDeficit_integrity(uint256 assetId, uint256 amount, address spoke) { + env e; + requireAllInvariants(assetId, e); + uint256 spokeAddedShares_ = hub._spokes[assetId][e.msg.sender].addedShares; + uint256 liquidityBefore = hub._assets[assetId].liquidity; + uint256 drawnSharesBefore = hub._spokes[assetId][e.msg.sender].drawnShares; + uint256 premiumSharesBefore = hub._spokes[assetId][e.msg.sender].premiumShares; + int200 premiumOffsetRayBefore = hub._spokes[assetId][e.msg.sender].premiumOffsetRay; + uint256 deficitRayBefore = hub._spokes[assetId][spoke].deficitRay; + uint256 senderDeficitRayBefore = hub._spokes[assetId][e.msg.sender].deficitRay; + + eliminateDeficit(e, assetId, amount, spoke); + + assert spokeAddedShares_ >= hub._spokes[assetId][e.msg.sender].addedShares; + assert liquidityBefore == hub._assets[assetId].liquidity; + assert drawnSharesBefore == hub._spokes[assetId][e.msg.sender].drawnShares; + assert premiumSharesBefore == hub._spokes[assetId][e.msg.sender].premiumShares; + assert premiumOffsetRayBefore == hub._spokes[assetId][e.msg.sender].premiumOffsetRay; + assert deficitRayBefore > hub._spokes[assetId][spoke].deficitRay; + assert e.msg.sender != spoke => senderDeficitRayBefore == hub._spokes[assetId][e.msg.sender].deficitRay; +} + +/** + * @title Sweep operation increases liquidity, external balance, to balance, swept, spoke added shares, drawn shares, premium shares, premium offset, and deficit ray + * @link_property Hub integrity + */ +rule sweep_integrity(uint256 assetId, uint256 amount) { + env e; + requireAllInvariants(assetId, e); + address asset = hub._assets[assetId].underlying; + uint256 liquidityBefore = hub._assets[assetId].liquidity; + uint256 externalBalanceBefore = balanceByToken[asset][hub]; + uint256 toBalanceBefore = balanceByToken[asset][e.msg.sender]; + uint256 sweptBefore = hub._assets[assetId].swept; + uint256 spokeAddedShares_ = hub._spokes[assetId][e.msg.sender].addedShares; + uint256 drawnSharesBefore = hub._spokes[assetId][e.msg.sender].drawnShares; + uint256 premiumSharesBefore = hub._spokes[assetId][e.msg.sender].premiumShares; + int200 premiumOffsetRayBefore = hub._spokes[assetId][e.msg.sender].premiumOffsetRay; + uint256 deficitRayBefore = hub._spokes[assetId][e.msg.sender].deficitRay; + + sweep(e, assetId, amount); + + assert liquidityBefore == hub._assets[assetId].liquidity + amount; + assert e.msg.sender != hub => externalBalanceBefore == balanceByToken[asset][hub] + amount; + assert e.msg.sender != hub => toBalanceBefore == balanceByToken[asset][e.msg.sender] - amount; + assert sweptBefore == hub._assets[assetId].swept - amount; + assert spokeAddedShares_ == hub._spokes[assetId][e.msg.sender].addedShares; + assert drawnSharesBefore == hub._spokes[assetId][e.msg.sender].drawnShares; + assert premiumSharesBefore == hub._spokes[assetId][e.msg.sender].premiumShares; + assert premiumOffsetRayBefore == hub._spokes[assetId][e.msg.sender].premiumOffsetRay; + assert deficitRayBefore == hub._spokes[assetId][e.msg.sender].deficitRay; + assert e.msg.sender == hub._assets[assetId].reinvestmentController; +} + +/** + * @title Reclaim operation decreases liquidity, swept, spoke added shares, drawn shares, premium shares, premium offset, and deficit ray + * @link_property Hub integrity + */ +rule reclaim_integrity(uint256 assetId, uint256 amount) { + env e; + requireAllInvariants(assetId, e); + address asset = hub._assets[assetId].underlying; + uint256 liquidityBefore = hub._assets[assetId].liquidity; + uint256 sweptBefore = hub._assets[assetId].swept; + uint256 spokeAddedShares_ = hub._spokes[assetId][e.msg.sender].addedShares; + uint256 drawnSharesBefore = hub._spokes[assetId][e.msg.sender].drawnShares; + uint256 premiumSharesBefore = hub._spokes[assetId][e.msg.sender].premiumShares; + int200 premiumOffsetRayBefore = hub._spokes[assetId][e.msg.sender].premiumOffsetRay; + uint256 deficitRayBefore = hub._spokes[assetId][e.msg.sender].deficitRay; + + reclaim(e, assetId, amount); + + assert liquidityBefore == hub._assets[assetId].liquidity - amount; + assert sweptBefore == hub._assets[assetId].swept + amount; + assert spokeAddedShares_ == hub._spokes[assetId][e.msg.sender].addedShares; + assert drawnSharesBefore == hub._spokes[assetId][e.msg.sender].drawnShares; + assert premiumSharesBefore == hub._spokes[assetId][e.msg.sender].premiumShares; + assert premiumOffsetRayBefore == hub._spokes[assetId][e.msg.sender].premiumOffsetRay; + assert deficitRayBefore == hub._spokes[assetId][e.msg.sender].deficitRay; + assert e.msg.sender == hub._assets[assetId].reinvestmentController; +} + +/** + * @title reportDeficit return same value as previewRestoreByAssets + * @link_property Hub integrity + */ +rule reportDeficitSameAsPreviewRestoreByAssets(uint256 assetId, uint256 drawnAmount) { + env e; + address spoke = e.msg.sender; + IHubBase.PremiumDelta premiumDelta; + requireAllInvariants(assetId, e); + storage init = lastStorage; + uint256 resultPreview = previewRestoreByAssets(e, assetId, drawnAmount); + uint256 resultReportDeficit = reportDeficit(e, assetId, drawnAmount, premiumDelta); + assert resultReportDeficit == resultPreview; +} + +/** + * @title Only valid active spoke can call the function and perform changes the spoke position (except for receiving fee shares) + * @link_property Hub integrity + */ +rule validSpokeOnly(uint256 assetId, method f) { + env e; + calldataarg args; + address spoke = e.msg.sender; + uint256 drawnShares = hub._spokes[assetId][spoke].drawnShares; + uint256 addedShares = hub._spokes[assetId][spoke].addedShares; + uint256 premiumShares = hub._spokes[assetId][spoke].premiumShares; + int200 premiumOffsetRay = hub._spokes[assetId][spoke].premiumOffsetRay; + uint200 deficitRay = hub._spokes[assetId][spoke].deficitRay; + bool active = hub._spokes[assetId][spoke].active; + + f(e, args); + + assert drawnShares != hub._spokes[assetId][spoke].drawnShares => active; + assert addedShares != hub._spokes[assetId][spoke].addedShares => (active || (hub._assets[assetId].feeReceiver == spoke && f.selector == sig:payFeeShares(uint256,uint256).selector)); + assert premiumShares != hub._spokes[assetId][spoke].premiumShares => active; + assert deficitRay != hub._spokes[assetId][spoke].deficitRay => active; + assert premiumOffsetRay != hub._spokes[assetId][spoke].premiumOffsetRay => active; +} + +/** + * @title Order of refreshPremium of two different spoke should not change reverting cases + * @link_property Hub integrity + */ +rule frontRunOnRefreshPremium(uint256 assetId) { + env e; + env eBefore; + calldataarg args; + + require eBefore.msg.sender != e.msg.sender; + require eBefore.block.timestamp <= e.block.timestamp; + + requireAllInvariants(assetId, eBefore); + requireInvariant premiumOffset_Integrity(assetId, e.msg.sender); + calldataarg argsRefresh; + storage init_state = lastStorage; + refreshPremium(e, argsRefresh); + refreshPremium(eBefore, args); + refreshPremium(eBefore, args) at init_state; + // just to avoid overflows + require getAddedAssets(e, assetId) >= getAddedShares(e, assetId); + refreshPremium@withrevert(e, argsRefresh); + assert !lastReverted; +} diff --git a/certora/spec/HubValidState.spec b/certora/spec/HubValidState.spec new file mode 100644 index 000000000..4c5b35909 --- /dev/null +++ b/certora/spec/HubValidState.spec @@ -0,0 +1,596 @@ +/** + * @title Hub Valid State Specification + * @notice Verify Hub valid state properties + * @dev Assumes a given single drawnIndex and that accrue was called on the asset + * + * To run this spec file: + * certoraRun certora/conf/HubValidState.conf + */ + +import "./symbolicRepresentation/ERC20s_CVL.spec"; +import "./symbolicRepresentation/Math_CVL.spec"; +import "./HubBase.spec"; + +using Hub as hub; + +//////////////////////////////////////////////////////////////////////////// +// METHODS // +//////////////////////////////////////////////////////////////////////////// + +methods { + + // assume that drawn rate was already updated. + //rules concerning updateDrawnRate are in HubAccrueIntegrity.spec + function AssetLogic.updateDrawnRate( + IHub.Asset storage asset, + uint256 assetId + ) internal => NONDET; + + // Assume a given single drawnIndex + // Rules concerning getDrawnIndex are in HubAccrueIntegrity.spec + function AssetLogic.getDrawnIndex(IHub.Asset storage asset) internal returns (uint256) => cachedIndex; + + // Rules concerning accrue are in HubAccrueIntegrity.spec + // In this spec we assume that the asset is not being accrued for fees, so getUnrealizedFees returns 0 + function AssetLogic.accrue(IHub.Asset storage asset) internal => accrueCalled(); + function AssetLogic.getUnrealizedFees( + IHub.Asset storage asset, + uint256 drawnIndex + ) internal returns (uint256) => 0; +} + +//////////////////////////////////////////////////////////////////////////// +// GHOST VARIABLES // +//////////////////////////////////////////////////////////////////////////// + +// Assume a given single drawnIndex +ghost uint256 cachedIndex; + +ghost mapping(uint256 /*assetId*/ => mapping(address /*spokeId*/ => uint256)) spokeAddedSharesMirror { + init_state axiom forall uint256 X. forall address Y. spokeAddedSharesMirror[X][Y] == 0; + init_state axiom forall uint256 X. (usum address a. spokeAddedSharesMirror[X][a]) == 0; +} + +ghost mapping(uint256 /*assetId*/ => mapping(address /*spokeId*/ => uint256)) spokePremiumSharesMirror { + init_state axiom forall uint256 X. forall address Y. spokePremiumSharesMirror[X][Y] == 0; + init_state axiom forall uint256 X. (usum address a. spokePremiumSharesMirror[X][a]) == 0; +} + +ghost mapping(uint256 /*assetId*/ => mapping(address /*spokeId*/ => uint256)) spokeDrawnSharesMirror { + init_state axiom forall uint256 X. forall address Y. spokeDrawnSharesMirror[X][Y] == 0; + init_state axiom forall uint256 X. (usum address a. spokeDrawnSharesMirror[X][a]) == 0; +} + +ghost mapping(uint256 /*assetId*/ => mapping(address /*spokeId*/ => int200)) spokePremiumOffsetMirror { + init_state axiom forall uint256 X. forall address Y. spokePremiumOffsetMirror[X][Y] == 0; + init_state axiom forall uint256 X. (sum address a. spokePremiumOffsetMirror[X][a]) == 0; +} + +ghost mapping(uint256 /*assetId*/ => mapping(address /*spokeId*/ => uint256)) spokeDeficitMirror { + init_state axiom forall uint256 X. forall address Y. spokeDeficitMirror[X][Y] == 0; + init_state axiom forall uint256 X. (usum address a. spokeDeficitMirror[X][a]) == 0; +} + +ghost bool accrueCalledOnAsset; +// Record accessed to debt fields before accrue +ghost bool unsafeAccessBeforeAccrue; + +// Ghosts for _assetToSpokes EnumerableSet to keep track of the spokes for an asset +// Part of proving validAssetId invariant +// For every storage variable we add a ghost field that is kept synchronized by hooks. +// The ghost fields can be accessed by the spec, even inside quantifiers. + +// Ghost field for the _values array +ghost mapping(uint256 => mapping(mathint => bytes32)) assetToSpokeValues { + init_state axiom forall uint256 assetId. forall mathint x. assetToSpokeValues[assetId][x] == to_bytes32(0); +} + +// Ghost field for the _positions map +ghost mapping(uint256 => mapping(bytes32 => uint256)) assetToSpokeIndexes { + init_state axiom forall uint256 assetId. forall bytes32 x. assetToSpokeIndexes[assetId][x] == 0; +} + +// Ghost field for the length of the values array (stored in offset 0) +ghost mapping(uint256 => uint256) assetToSpokeLength { + init_state axiom forall uint256 assetId. assetToSpokeLength[assetId] == 0; + // Assumption: it's infeasible to grow the list to these many elements. + axiom forall uint256 assetId. assetToSpokeLength[assetId] < max_uint256; +} + +// Optimize calls to getAddedAssets, getAddedShares function and save in ghost (global) variable +ghost uint256 addedAssetsBefore; +ghost uint256 addedSharesBefore; + +//////////////////////////////////////////////////////////////////////////// +// DEFINITIONS // +//////////////////////////////////////////////////////////////////////////// + +definition emptyAsset(uint256 assetId) returns bool = + hub._assets[assetId].addedShares == 0 && + hub._assets[assetId].liquidity == 0 && + hub._assets[assetId].addedShares == 0 && + hub._assets[assetId].deficitRay == 0 && + hub._assets[assetId].swept == 0 && + hub._assets[assetId].premiumShares == 0 && + hub._assets[assetId].premiumOffsetRay == 0 && + hub._assets[assetId].drawnShares == 0 && + hub._assets[assetId].drawnIndex == 0 && + hub._assets[assetId].drawnRate == 0 && + hub._assets[assetId].lastUpdateTimestamp == 0 && + hub._assets[assetId].underlying == 0 && + (forall address spokeId. + hub._spokes[assetId][spokeId].addedShares == 0 && + hub._spokes[assetId][spokeId].drawnShares == 0 && + hub._spokes[assetId][spokeId].premiumShares == 0 && + hub._spokes[assetId][spokeId].premiumOffsetRay == 0 && + !hub._spokes[assetId][spokeId].active && + assetToSpokeIndexes[assetId][to_bytes32(spokeId)] == 0 + ); + +//////////////////////////////////////////////////////////////////////////// +// HELPER FUNCTIONS // +//////////////////////////////////////////////////////////////////////////// + +/** + * @notice Function summary for accrue called + */ +function accrueCalled() { + accrueCalledOnAsset = true; +} + +/** + * @notice Require all invariants for an asset + */ +function requireAllInvariants(uint256 assetId, env e) { + // Optimize (reuse) the calls to getAddedAssets() and getTotalAddedShares() + addedAssetsBefore = hub.getAddedAssets(e, assetId); + addedSharesBefore = hub.getAddedShares(e, assetId); + // requireInvariant totalAssetsVsShares(assetId,e); + require addedAssetsBefore >= addedSharesBefore, "optimization"; + + requireInvariant solvency_external(assetId); + requireInvariant sumOfSpokeDrawnShares(assetId); + requireInvariant sumOfSpokeSupplyShares(assetId); + requireInvariant sumOfSpokePremiumDrawnShares(assetId); + requireInvariant sumOfSpokePremiumOffset(assetId); + requireInvariant drawnIndexMin(assetId); + requireInvariant assetToSpokesIntegrity(assetId); + requireInvariant validAssetId(assetId); + requireInvariant liquidityFee_upper_bound(assetId); + require cachedIndex == hub._assets[assetId].drawnIndex; +} + +//////////////////////////////////////////////////////////////////////////// +// HOOKS // +//////////////////////////////////////////////////////////////////////////// + +// ========== Asset Hooks (in struct order) ========== + +// 1. liquidity (uint120) +hook Sstore hub._assets[KEY uint256 assetId].liquidity uint120 new_value (uint120 old_value) { + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} +hook Sload uint120 value hub._assets[KEY uint256 assetId].liquidity { + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} + +// 2. realizedFees (uint120) +hook Sstore hub._assets[KEY uint256 assetId].realizedFees uint120 new_value (uint120 old_value) { + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} +hook Sload uint120 value hub._assets[KEY uint256 assetId].realizedFees { + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} + +// 3. decimals (uint8) +hook Sstore hub._assets[KEY uint256 assetId].decimals uint8 new_value (uint8 old_value) { + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} +hook Sload uint8 value hub._assets[KEY uint256 assetId].decimals { + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} + +// 4. addedShares (uint120) +hook Sstore hub._assets[KEY uint256 assetId].addedShares uint120 new_value (uint120 old_value) { + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} +hook Sload uint120 value hub._assets[KEY uint256 assetId].addedShares { + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} + +// 5. swept (uint120) +hook Sstore hub._assets[KEY uint256 assetId].swept uint120 new_value (uint120 old_value) { + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} +hook Sload uint120 value hub._assets[KEY uint256 assetId].swept { + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} + +// 6. premiumOffsetRay (int200) +hook Sstore hub._assets[KEY uint256 assetId].premiumOffsetRay int200 new_value (int200 old_value) { + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} +hook Sload int200 value hub._assets[KEY uint256 assetId].premiumOffsetRay { + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} + +// 7. drawnShares (uint120) +hook Sstore hub._assets[KEY uint256 assetId].drawnShares uint120 new_value (uint120 old_value) { + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} +hook Sload uint120 value hub._assets[KEY uint256 assetId].drawnShares { + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} + +// 8. premiumShares (uint120) +hook Sstore hub._assets[KEY uint256 assetId].premiumShares uint120 new_value (uint120 old_value) { + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} +hook Sload uint120 value hub._assets[KEY uint256 assetId].premiumShares { + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} + +// 9. liquidityFee (uint16) +hook Sstore hub._assets[KEY uint256 assetId].liquidityFee uint16 new_value (uint16 old_value) { + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} +hook Sload uint16 value hub._assets[KEY uint256 assetId].liquidityFee { + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} + +// 10. drawnIndex (uint120) +hook Sstore hub._assets[KEY uint256 assetId].drawnIndex uint120 new_value (uint120 old_value) { + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} +hook Sload uint120 value hub._assets[KEY uint256 assetId].drawnIndex { + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} + +// 11. drawnRate (uint96) +hook Sstore hub._assets[KEY uint256 assetId].drawnRate uint96 new_value (uint96 old_value) { + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} +hook Sload uint96 value hub._assets[KEY uint256 assetId].drawnRate { + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} + +// 12. lastUpdateTimestamp (uint40) +hook Sstore hub._assets[KEY uint256 assetId].lastUpdateTimestamp uint40 new_value (uint40 old_value) { + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} +hook Sload uint40 value hub._assets[KEY uint256 assetId].lastUpdateTimestamp { + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} + +// 13. underlying (address) - no need to call accrue for this +// 14. irStrategy (address) - no need to call accrue for this +// 15. reinvestmentController (address) - no need to call accrue for this +// 16. feeReceiver (address) - no need to call accrue for this + +// 17. deficitRay (uint200) +hook Sstore hub._assets[KEY uint256 assetId].deficitRay uint200 new_value (uint200 old_value) { + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} +hook Sload uint200 value hub._assets[KEY uint256 assetId].deficitRay { + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} + +// ========== Spoke Hooks (in struct order) ========== + +// 1. drawnShares (uint120) +hook Sstore hub._spokes[KEY uint256 assetId][KEY address spokeId].drawnShares uint120 new_value (uint120 old_value) { + spokeDrawnSharesMirror[assetId][spokeId] = new_value; + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} +hook Sload uint120 value hub._spokes[KEY uint256 assetId][KEY address spokeId].drawnShares { + require spokeDrawnSharesMirror[assetId][spokeId] == value; + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} + +// 2. premiumShares (uint120) +hook Sstore hub._spokes[KEY uint256 assetId][KEY address spokeId].premiumShares uint120 new_value (uint120 old_value) { + spokePremiumSharesMirror[assetId][spokeId] = new_value; + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} +hook Sload uint120 value hub._spokes[KEY uint256 assetId][KEY address spokeId].premiumShares { + require spokePremiumSharesMirror[assetId][spokeId] == value; + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} + +// 3. premiumOffsetRay (int200) +hook Sstore hub._spokes[KEY uint256 assetId][KEY address spokeId].premiumOffsetRay int200 new_value (int200 old_value) { + spokePremiumOffsetMirror[assetId][spokeId] = new_value; + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} +hook Sload int200 value hub._spokes[KEY uint256 assetId][KEY address spokeId].premiumOffsetRay { + require spokePremiumOffsetMirror[assetId][spokeId] == value; + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} + +// 4. addedShares (uint120) +hook Sstore hub._spokes[KEY uint256 assetId][KEY address spokeId].addedShares uint120 new_value (uint120 old_value) { + spokeAddedSharesMirror[assetId][spokeId] = new_value; + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} +hook Sload uint120 value hub._spokes[KEY uint256 assetId][KEY address spokeId].addedShares { + require spokeAddedSharesMirror[assetId][spokeId] == value; + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} + +// 5. addCap (uint40) - no need to call accrue for this +// 6. drawCap (uint40) - no need to call accrue for this +// 7. riskPremiumThreshold (uint24) - no need to call accrue for this +// 8. active (bool) - no need to call accrue for this +// 9. paused (bool) - no need to call accrue for this + +// 10. deficitRay (uint200) +hook Sstore hub._spokes[KEY uint256 assetId][KEY address spokeId].deficitRay uint200 new_value (uint200 old_value) { + spokeDeficitMirror[assetId][spokeId] = new_value; + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} +hook Sload uint200 value hub._spokes[KEY uint256 assetId][KEY address spokeId].deficitRay { + require spokeDeficitMirror[assetId][spokeId] == value; + unsafeAccessBeforeAccrue = unsafeAccessBeforeAccrue || !accrueCalledOnAsset; +} +/**** Valid State Rules *******/ + + + +/** @title Integrity of a validAsset +* @link_property valid state +**/ +invariant validAssetId(uint256 assetId) + // Ensure that the underlying 0 is not valid + hub._underlyingToAssetId[0] == 0 && + // And assetId above assetCount is empty + (assetId >= hub._assetCount => emptyAsset(assetId)) && + // Existence of the asset + (assetId < hub._assetCount => + // Uniqueness of underlying + (hub._underlyingToAssetId[hub._assets[assetId].underlying] == assetId) && + // Each underlying has a unique assetID + (forall address underlying. (underlying != 0 && hub._underlyingToAssetId[underlying] != 0) => + (hub._underlyingToAssetId[underlying] < hub._assetCount && hub._assets[hub._underlyingToAssetId[underlying]].underlying == underlying) + && + (hub._underlyingToAssetId[underlying] == 0 && hub._assetCount > 0 => hub._assets[0].underlying == underlying)) + && (forall uint256 otherAssetId. (otherAssetId != assetId && otherAssetId < hub._assetCount) => hub._assets[otherAssetId].underlying != hub._assets[assetId].underlying)) + filtered {f -> f.selector != sig:hub.addAsset(address,uint8,address,address,bytes).selector} + { + preserved { + requireInvariant assetToSpokesIntegrity(assetId); + requireInvariant validAssetId(hub._assetCount); + } + } + +/** + * @title Integrity of a valid asset, with specific underlying to help grounding on addAsset call + * @link_property valid state + */ +invariant validAssetId_underlying(uint256 assetId, address underlying, uint256 otherAssetId) + // Ensure that the underlying 0 is not valid + hub._underlyingToAssetId[0] == 0 && + // And assetId above assetCount is empty + (assetId >= hub._assetCount => emptyAsset(assetId)) && + // Existence of the asset + (assetId < hub._assetCount => + // Uniqueness of underlying + (hub._underlyingToAssetId[hub._assets[assetId].underlying] == assetId) && + // Each underlying has a unique assetID + (underlying != 0 && hub._underlyingToAssetId[underlying] != 0 => + (hub._underlyingToAssetId[underlying] < hub._assetCount && hub._assets[hub._underlyingToAssetId[underlying]].underlying == underlying)) + && + (hub._underlyingToAssetId[underlying] == 0 && hub._assetCount > 0 => hub._assets[0].underlying == underlying) && + (otherAssetId != assetId && otherAssetId < hub._assetCount) => hub._assets[otherAssetId].underlying != hub._assets[assetId].underlying) + { + preserved { + requireInvariant assetToSpokesIntegrity(assetId); + requireInvariant validAssetId(hub._assetCount); + } + preserved addAsset(address underlying_arg, uint8 decimals, address feeReceiver, address irStrategy, bytes irData) with (env e) { + require hub._assetCount < 5; + requireInvariant validAssetId(0); + requireInvariant validAssetId(1); + requireInvariant validAssetId(2); + requireInvariant validAssetId(3); + requireInvariant validAssetId(4); + require underlying_arg == underlying; + } + } + + + + +/** + * @title The sum of hub._spokes[assetId][spoke].addedShares for all spoke equals to hub._assets[assetId].addedShares + * @link_property valid state of aggregated data + */ +invariant sumOfSpokeSupplyShares(uint256 assetId) + hub._assets[assetId].addedShares == (usum address spokeId. spokeAddedSharesMirror[assetId][spokeId]) + { + preserved { + requireInvariant validAssetId(assetId); + } + } + +/** + * @title The sum of hub._spokes[assetId][spoke].drawnShares for all spoke equals to hub._assets[assetId].drawnShares + * @link_property valid state of aggregated data + */ +invariant sumOfSpokeDrawnShares(uint256 assetId) + hub._assets[assetId].drawnShares == (usum address spokeId. spokeDrawnSharesMirror[assetId][spokeId]) + { + preserved { + requireInvariant validAssetId(assetId); + } + } + +/** + * @title The sum of hub._spokes[assetId][spoke].premiumShares for all spoke equals to hub._assets[assetId].premiumShares + * @link_property valid state of aggregated data + */ +invariant sumOfSpokePremiumDrawnShares(uint256 assetId) + hub._assets[assetId].premiumShares == (usum address spokeId. spokePremiumSharesMirror[assetId][spokeId]) + { + preserved { + requireInvariant validAssetId(assetId); + } + } + +/** + * @title The sum of hub._spokes[assetId][spoke].premiumOffsetRay for all spoke equals to hub._assets[assetId].premiumOffsetRay + * @link_property valid state of aggregated data + */ +invariant sumOfSpokePremiumOffset(uint256 assetId) + hub._assets[assetId].premiumOffsetRay == (sum address spokeId. spokePremiumOffsetMirror[assetId][spokeId]) + { + preserved { + requireInvariant validAssetId(assetId); + } + } + +/** + * @title The sum of hub._spokes[assetId][spoke].deficitRay for all spoke equals to hub._assets[assetId].deficitRay + * @link_property valid state of aggregated data + */ +invariant sumOfSpokeDeficit(uint256 assetId) + hub._assets[assetId].deficitRay == (usum address spokeId. spokeDeficitMirror[assetId][spokeId]) + { + preserved { + requireInvariant validAssetId(assetId); + } + } + +/** + * @title DrawnIndex is greater than or equal to RAY on listed assets + * @link_property valid state + */ +invariant drawnIndexMin(uint256 assetId) + assetId < hub._assetCount => hub._assets[assetId].drawnIndex >= RAY + { + preserved { + requireInvariant validAssetId(assetId); + } + } + +/** + * @title LiquidityFee upper bound: config.liquidityFee must not exceed PercentageMathExtended.PERCENTAGE_FACTOR + * @link_property valid state + */ +invariant liquidityFee_upper_bound(uint256 assetId) + hub._assets[assetId].liquidityFee <= PERCENTAGE_FACTOR; + + +/** + * @title PremiumOffsetRay must not exceed the premiumShares on asset and spoke level + * @link_property valid state + */ +invariant premiumOffset_Integrity(uint256 assetId, address spokeId) + hub._assets[assetId].premiumShares * hub._assets[assetId].drawnIndex >= hub._assets[assetId].premiumOffsetRay && + hub._spokes[assetId][spokeId].premiumShares * hub._assets[assetId].drawnIndex >= hub._spokes[assetId][spokeId].premiumOffsetRay + { + preserved with (env e1) { + requireAllInvariants(assetId, e1); + } + + } + + +/** + * @title External balance is at least as internal accounting + * @link_property solvency + */ +strong invariant solvency_external(uint256 assetId) + balanceByToken[hub._assets[assetId].underlying][hub] >= hub._assets[assetId].liquidity + { + preserved with (env e1) { + require e1.msg.sender != hub; + requireAllInvariants(assetId, e1); + } + + preserved remove(uint256 assetId2, uint256 amount, address to) with (env e2) + { + requireAllInvariants(assetId2, e2); + requireAllInvariants(assetId, e2); + } + preserved draw(uint256 assetId2, uint256 amount, address to) with (env e3) + { + requireAllInvariants(assetId2, e3); + requireAllInvariants(assetId, e3); + } + + } + + +/** + * @title The sum of added assets is greater than or equal to the sum of added shares + * @link_property share rate integrity + */ +invariant totalAssetsVsShares(uint256 assetId, env e) + hub.getAddedAssets(e, assetId) >= hub.getAddedShares(e, assetId) + // Specific rule below + filtered {f -> f.selector != sig:hub.eliminateDeficit(uint256,uint256,address).selector} + { + preserved with (env eInv) { + require eInv.block.timestamp == e.block.timestamp; + requireAllInvariants(assetId, e); + } + } + +/** + * @title Total assets vs shares for eliminateDeficit + * @link_property share rate integrity + */ +rule totalAssetsVsShares_eliminateDeficit(uint256 assetId, uint256 amount, address spokeId) { + env e; + requireAllInvariants(assetId, e); + requireInvariant premiumOffset_Integrity(assetId, e.msg.sender); + eliminateDeficit(e, assetId, amount, spokeId); + assert hub.getAddedAssets(e, assetId) >= hub.getAddedShares(e, assetId); +} +// Store hook to synchronize assetToSpokeLength with the length of the set._inner._values array. +hook Sstore hub._assetToSpokes[KEY uint256 assetId]._inner._values.length uint256 newLength { + assetToSpokeLength[assetId] = newLength; +} + +// Store hook to synchronize assetToSpokeValues array with set._inner._values. +hook Sstore hub._assetToSpokes[KEY uint256 assetId]._inner._values[INDEX uint256 index] bytes32 newValue { + assetToSpokeValues[assetId][index] = newValue; +} + +// Store hook to synchronize assetToSpokeIndexes array with set._inner._positions. +hook Sstore hub._assetToSpokes[KEY uint256 assetId]._inner._positions[KEY bytes32 value] uint256 newIndex { + assetToSpokeIndexes[assetId][value] = newIndex; +} + +// The load hooks can use require to ensure that the ghost field has the same information as the storage. +// The require is sound, since the store hooks ensure the contents are always the same. However we cannot +// prove that with invariants, since this would require the invariant to read the storage for all elements +// and neither storage access nor function calls are allowed in quantifiers. +// +// By following this simple pattern it is ensured that the ghost state and the storage are always the same +// and that the solver can use this knowledge in the proofs. + +// Load hook to synchronize assetToSpokeLength with the length of the set._inner._values array. +hook Sload uint256 length hub._assetToSpokes[KEY uint256 assetId]._inner._values.length { + require assetToSpokeLength[assetId] == length; +} + +hook Sload bytes32 value hub._assetToSpokes[KEY uint256 assetId]._inner._values[INDEX uint256 index] { + require assetToSpokeValues[assetId][index] == value; +} + +hook Sload uint256 index hub._assetToSpokes[KEY uint256 assetId]._inner._positions[KEY bytes32 value] { + require assetToSpokeIndexes[assetId][value] == index; +} + +/** + * @title AssetToSpokes integrity: indexes and values always match + * @notice This is the main invariant stating that the indexes and values always match: + * values[indexes[v] - 1] = v for all values v in the set + * and indexes[values[i]] = i+1 for all valid indexes i. + * @link_property valid state + */ +invariant assetToSpokesIntegrity(uint256 assetId) + (forall uint256 index. 0 <= index && index < assetToSpokeLength[assetId] => to_mathint(assetToSpokeIndexes[assetId][assetToSpokeValues[assetId][index]]) == index + 1) + && (forall bytes32 value. assetToSpokeIndexes[assetId][value] == 0 || + (assetToSpokeValues[assetId][assetToSpokeIndexes[assetId][value] - 1] == value && assetToSpokeIndexes[assetId][value] >= 1 && assetToSpokeIndexes[assetId][value] <= assetToSpokeLength[assetId])); \ No newline at end of file diff --git a/certora/spec/Liquidation.spec b/certora/spec/Liquidation.spec new file mode 100644 index 000000000..3c73ffa95 --- /dev/null +++ b/certora/spec/Liquidation.spec @@ -0,0 +1,190 @@ +/** + * @title Liquidation Specification + * @notice Specification for liquidation operations and properties + * @dev This spec verifies liquidation call properties including health factor checks, debt monotonicity, and account isolation. + * + * Verification Scope: + * - Health factor validation: Healthy accounts cannot be liquidated. + * - Debt monotonicity: Debt decreases during successful liquidation. + * - Account isolation: Only the liquidated account's debt changes. + * - Pause behavior: Liquidation fails when paused. + */ + +import "./SpokeBase.spec"; +import "./symbolicRepresentation/SymbolicPositionStatus.spec"; +import "./symbolicRepresentation/SymbolicHub.spec"; + +//////////////////////////////////////////////////////////////////////////// +// METHODS // +//////////////////////////////////////////////////////////////////////////// + +methods { + // based on spec LiquidationLogic_Bonus.spec + function LiquidationLogic.calculateLiquidationBonus(uint256, uint256, uint256, uint256) internal returns (uint256) => NONDET; + + function Spoke._processUserAccountData(address user, bool refreshConfig) internal returns (ISpoke.UserAccountData memory) => processUserAccountDataCVL(user, refreshConfig); + + // pure function - safe to assume NONDET + function LiquidationLogic._calculateDebtToTargetHealthFactor(LiquidationLogic.CalculateDebtToTargetHealthFactorParams memory) internal returns (uint256) => NONDET; +} + +//////////////////////////////////////////////////////////////////////////// +// GHOSTS // +//////////////////////////////////////////////////////////////////////////// + +persistent ghost mapping(address => uint256) ghostHealthFactor { + init_state axiom forall address user. ghostHealthFactor[user] == 0; +} + +//////////////////////////////////////////////////////////////////////////// +// DEFINITIONS // +//////////////////////////////////////////////////////////////////////////// + +function processUserAccountDataCVL(address user, bool refreshConfig) returns (ISpoke.UserAccountData) { + ISpoke.UserAccountData userAccountData; + require userAccountData.healthFactor == ghostHealthFactor[user]; + require userAccountData.activeCollateralCount <= reserveCountGhost; + return userAccountData; +} + +definition HEALTH_FACTOR_LIQUIDATION_THRESHOLD() returns uint256 = 10 ^ 18; + +//////////////////////////////////////////////////////////////////////////// +// RULES // +//////////////////////////////////////////////////////////////////////////// + +/** + * @title Sanity check - liquidation call can succeed + */ +rule sanityCheck() { + env e; + setup(); + calldataarg args; + liquidationCall(e, args); + satisfy true; +} + +/** + * @title Borrowing flag set if and only if drawn shares exist + * @notice Assuming one user's borrowing flag is set at a time - proven in LiquidationUserIntegrity.spec + * @link_property liquidation call integrity, borrowing flag integrity + */ +rule borrowingFlagSetIFFdrawnShares_liquidationCall(uint256 reserveId, address user) { + env e; + setup(); + require userGhost == user; + uint256 collateralReserveId; + uint256 debtReserveId; + uint256 debtToCover; + bool receiveShares; + + require spoke._userPositions[user][reserveId].drawnShares > 0 <=> isBorrowing[user][reserveId]; + liquidationCall(e, collateralReserveId, debtReserveId, user, debtToCover, receiveShares); + assert spoke._userPositions[user][reserveId].drawnShares > 0 <=> isBorrowing[user][reserveId]; +} + +/** + * @title Healthy account cannot be liquidated + * @link_property liquidation call integrity, health check validity + */ +rule healthyAccountCannotBeLiquidated(uint256 reserveId, address user) { + env e; + setup(); + require userGhost == user; + uint256 collateralReserveId; + uint256 debtReserveId; + uint256 debtToCover; + bool receiveShares; + require ghostHealthFactor[user] >= HEALTH_FACTOR_LIQUIDATION_THRESHOLD(); + liquidationCall@withrevert(e, collateralReserveId, debtReserveId, user, debtToCover, receiveShares); + assert lastReverted; +} + +/** + * @title When paused (collateral or debt) then no liquidation + * @link_property liquidation call integrity,pause behavior + */ +rule paused_noLiquidation(uint256 reserveId, address userLiquidated, address liquidator) { + env e; + setup(); + require e.msg.sender == liquidator; + uint256 collateralReserveId; + uint256 debtReserveId; + uint256 debtToCover; + bool receiveShares; + require liquidator != spoke._reserves[collateralReserveId].hub; + require pausedGhost; + liquidationCall@withrevert(e, collateralReserveId, debtReserveId, userLiquidated, debtToCover, receiveShares); + assert lastReverted; +} + +/** + * @title Monotonicity of debt decrease during liquidation + * @notice If collateral increases for the liquidator, then debt must decrease for the liquidated user + * @link_property liquidation call integrity + */ +rule monotonicityOfDebtDecrease_collateralIncrease(uint256 reserveId, address userLiquidated, address liquidator) { + env e; + setup(); + require e.msg.sender == liquidator; + uint256 collateralReserveId; + uint256 debtReserveId; + uint256 debtToCover; + bool receiveShares; + require liquidator != spoke._reserves[collateralReserveId].hub; + + require spoke._userPositions[userLiquidated][debtReserveId].drawnShares > 0 <=> isBorrowing[userLiquidated][debtReserveId]; + + address underlyingCollateral = spoke._reserves[collateralReserveId].underlying; + require underlyingCollateral == assetUnderlying[spoke._reserves[collateralReserveId].assetId]; + + uint256 beforeDrawnShares = spoke._userPositions[userLiquidated][debtReserveId].drawnShares; + uint256 beforeUnderlyingCollateralBalance = tokenBalanceOf(underlyingCollateral, liquidator); + uint256 beforeCollateral = spoke._userPositions[liquidator][collateralReserveId].suppliedShares; + + mathint beforePremiumDebt = (spoke._userPositions[userLiquidated][debtReserveId].premiumShares * getAssetDrawnIndexCVL(spoke._reserves[debtReserveId].assetId, e)) - spoke._userPositions[userLiquidated][debtReserveId].premiumOffsetRay; + + liquidationCall(e, collateralReserveId, debtReserveId, userLiquidated, debtToCover, receiveShares); + + uint256 afterDrawnShares = spoke._userPositions[userLiquidated][debtReserveId].drawnShares; + uint256 afterUnderlyingCollateralBalance = tokenBalanceOf(underlyingCollateral, liquidator); + uint256 afterCollateral = spoke._userPositions[liquidator][collateralReserveId].suppliedShares; + + mathint afterPremiumDebt = (spoke._userPositions[userLiquidated][debtReserveId].premiumShares * getAssetDrawnIndexCVL(spoke._reserves[debtReserveId].assetId, e)) - spoke._userPositions[userLiquidated][debtReserveId].premiumOffsetRay; + + assert (beforeCollateral < afterCollateral || beforeUnderlyingCollateralBalance < afterUnderlyingCollateralBalance) => + (afterDrawnShares < beforeDrawnShares || afterPremiumDebt < beforePremiumDebt); + satisfy (beforeCollateral < afterCollateral || beforeUnderlyingCollateralBalance < afterUnderlyingCollateralBalance); +} + + + +/** + * @title No change to other accounts during liquidation + * @notice In the liquidated account debt can be decreased to zero on report deficit, however other collateral cannot change at all + * @link_property liquidation call integrity + */ +rule noChangeToOtherAccounts_liquidationCall(uint256 reserveId, address userLiquidated, address liquidator, address user) { + env e; + setup(); + require e.msg.sender == liquidator; + uint256 collateralReserveId; + uint256 debtReserveId; + uint256 debtToCover; + bool receiveShares; + + uint256 drawnSharesBefore = spoke._userPositions[user][reserveId].drawnShares; + uint256 premiumSharesBefore = spoke._userPositions[user][reserveId].premiumShares; + int256 premiumOffsetRayBefore = spoke._userPositions[user][reserveId].premiumOffsetRay; + uint256 suppliedSharesBefore = spoke._userPositions[user][reserveId].suppliedShares; + + liquidationCall(e, collateralReserveId, debtReserveId, userLiquidated, debtToCover, receiveShares); + + assert spoke._userPositions[user][reserveId].drawnShares != drawnSharesBefore => (user == userLiquidated); + assert spoke._userPositions[user][reserveId].premiumShares != premiumSharesBefore => (user == userLiquidated); + assert spoke._userPositions[user][reserveId].premiumOffsetRay != premiumOffsetRayBefore => (user == userLiquidated); + assert spoke._userPositions[user][reserveId].suppliedShares != suppliedSharesBefore => + (reserveId == collateralReserveId && + ((user == userLiquidated && spoke._userPositions[user][reserveId].suppliedShares < suppliedSharesBefore) || + (user == liquidator && spoke._userPositions[user][reserveId].suppliedShares > suppliedSharesBefore && receiveShares && !frozenGhost))); +} \ No newline at end of file diff --git a/certora/spec/LiquidationIntegrity.spec b/certora/spec/LiquidationIntegrity.spec new file mode 100644 index 000000000..364ebbd9d --- /dev/null +++ b/certora/spec/LiquidationIntegrity.spec @@ -0,0 +1,92 @@ +/** + * @title Liquidation Integrity Specification + * @notice Verify that what returned from calculateLiquidationAmounts is the actual change to the user position + * @dev This spec ensures that the liquidation amounts calculated match the actual state changes + */ + +import "./Liquidation.spec"; + + +//////////////////////////////////////////////////////////////////////////// +// METHODS // +//////////////////////////////////////////////////////////////////////////// + +methods { + function LiquidationLogic._calculateLiquidationAmounts(LiquidationLogic.CalculateLiquidationAmountsParams memory params) internal returns (LiquidationLogic.LiquidationAmounts memory) => calculateLiquidationAmountsCVL(params); +} + +//////////////////////////////////////////////////////////////////////////// +// GHOSTS // +//////////////////////////////////////////////////////////////////////////// + +ghost uint256 ghostDrawnSharesToLiquidate; +ghost uint256 ghostPremiumDebtRayToLiquidate; +ghost uint256 ghostCollateralSharesToLiquidate; +ghost uint256 ghostCollateralSharesToLiquidator; +ghost bool totalMoreThanDebtValue; + +//////////////////////////////////////////////////////////////////////////// +// DEFINITIONS // +//////////////////////////////////////////////////////////////////////////// + + +definition premiumDebtCVL(address user, uint256 reserveId, env e) returns mathint = + (spoke._userPositions[user][reserveId].premiumShares * getAssetDrawnIndexCVL(spoke._reserves[reserveId].assetId, e)) - spoke._userPositions[user][reserveId].premiumOffsetRay; + + +function calculateLiquidationAmountsCVL(LiquidationLogic.CalculateLiquidationAmountsParams params) returns (LiquidationLogic.LiquidationAmounts) { + LiquidationLogic.LiquidationAmounts result; + require result.drawnSharesToLiquidate == ghostDrawnSharesToLiquidate; + require result.premiumDebtRayToLiquidate == ghostPremiumDebtRayToLiquidate; + require result.collateralSharesToLiquidate == ghostCollateralSharesToLiquidate; + require result.collateralSharesToLiquidator == ghostCollateralSharesToLiquidator; + // rule drawnSharesZeroed_premiumDebtRayZeroed in LiquidationLogic.spec + require params.drawnShares == ghostDrawnSharesToLiquidate => params.premiumDebtRay == ghostPremiumDebtRayToLiquidate; + + uint256 debtInAssets = require_uint256(mulDivUpCVL(params.drawnShares, params.drawnIndex, RAY) + divRayUpCVL(params.premiumDebtRay)); + mathint debtValue = toValueCVL(debtInAssets, params.debtAssetDecimals, params.debtAssetPrice); + if (params.totalDebtValueRay < debtValue) { + totalMoreThanDebtValue = true; + } + + return result; +} + +//////////////////////////////////////////////////////////////////////////// +// RULES // +//////////////////////////////////////////////////////////////////////////// + +/** + * @title Liquidation integrity - calculated amounts match actual state changes + * @link_property liquidation call integrity + */ +rule liquidationIntegrity(uint256 reserveId, address userLiquidated) { + env e; + setup(); + // help grounding + uint256 nextBorrowingId = nextBorrowingCVL(spoke._reserveCount); + require nextBorrowingId == reserveId || reserveId == nextBorrowingCVL(spoke._reserveCount); + + uint256 collateralReserveId; + uint256 debtReserveId; + uint256 debtToCover; + bool receiveShares; + + require reserveId == debtReserveId; + require !deficitReportedFlag; + + uint256 drawnSharesBefore = spoke._userPositions[userLiquidated][debtReserveId].drawnShares; + uint256 premiumSharesBefore = spoke._userPositions[userLiquidated][debtReserveId].premiumShares; + mathint premiumOffsetRayBefore = premiumDebtCVL(userLiquidated, debtReserveId, e); + uint256 suppliedSharesBefore = spoke._userPositions[userLiquidated][collateralReserveId].suppliedShares; + + liquidationCall(e, collateralReserveId, debtReserveId, userLiquidated, debtToCover, receiveShares); + + // in case of no report deficit, the drawn shares and premium debt should reduce by exactly the returned value from calculateLiquidationAmounts + assert !deficitReportedFlag => spoke._userPositions[userLiquidated][debtReserveId].drawnShares == (drawnSharesBefore - ghostDrawnSharesToLiquidate); + assert !deficitReportedFlag => premiumDebtCVL(userLiquidated, debtReserveId, e) == (premiumOffsetRayBefore - ghostPremiumDebtRayToLiquidate); + // in case of report deficit, the drawn shares and premium debt should be zero + assert deficitReportedFlag => premiumDebtCVL(userLiquidated, debtReserveId, e) == 0 && spoke._userPositions[userLiquidated][debtReserveId].drawnShares == 0; + // the collateral shares should reduce by exactly the returned value from calculateLiquidationAmounts + assert spoke._userPositions[userLiquidated][collateralReserveId].suppliedShares == (suppliedSharesBefore - ghostCollateralSharesToLiquidate); +} diff --git a/certora/spec/LiquidationReportDeficit.spec b/certora/spec/LiquidationReportDeficit.spec new file mode 100644 index 000000000..d8aa29e31 --- /dev/null +++ b/certora/spec/LiquidationReportDeficit.spec @@ -0,0 +1,97 @@ +/** + * @title Liquidation Report Deficit Specification + * @notice Verify conditions under which deficit is reported during liquidation + * @dev This spec verifies when deficit reporting occurs based on collateral and debt values + */ + +import "./SpokeHealthFactor.spec"; + + +//////////////////////////////////////////////////////////////////////////// +// METHODS // +//////////////////////////////////////////////////////////////////////////// + +methods { + // pure functions - safe to assume NONDET + function LiquidationLogic.calculateLiquidationBonus(uint256, uint256, uint256, uint256) internal returns (uint256) => bonusGhost; + + function LiquidationLogic._calculateDebtToTargetHealthFactor(LiquidationLogic.CalculateDebtToTargetHealthFactorParams memory) internal returns (uint256) => NONDET; +} + +//////////////////////////////////////////////////////////////////////////// +// GHOSTS // +//////////////////////////////////////////////////////////////////////////// + +ghost uint256 bonusGhost { + axiom bonusGhost >= PERCENTAGE_FACTOR; +} + +/** + * @notice Per-reserve debt value consistent with SpokeHealthFactor's totalDebtValueGhost hooks: + * drawnShares * index + (premiumShares * index - premiumOffsetRay), all multiplied by price. + */ +function debtValueReserveId(uint256 reserveId) returns (mathint) { + uint256 assetId = spoke._reserves[reserveId].assetId; + mathint idx = indexOfAssetPerBlock[assetId][currentTime]; + + mathint drawnTerm = spoke._userPositions[currentUser][reserveId].drawnShares * idx; + mathint premiumTerm = + spoke._userPositions[currentUser][reserveId].premiumShares * idx + - spoke._userPositions[currentUser][reserveId].premiumOffsetRay; + + return (drawnTerm + premiumTerm) * symbolicPrice(reserveId, currentTime); +} + +//////////////////////////////////////////////////////////////////////////// +// RULES // +//////////////////////////////////////////////////////////////////////////// + +/** + * @title More than one collateral - no report deficit + * @link_property deficit reporting integrity + */ +rule moreThanOneCollateral_noReportDeficit(uint256 reserveId, address userLiquidated, address liquidator) { + env e; + setup(); + require e.msg.sender == liquidator; + uint256 debtReserveId; + uint256 debtToCover; + bool receiveShares; + require currentTime == e.block.timestamp; + require currentUser == userLiquidated; + + require !deficitReportedFlag; + mathint collateralIDValueBefore = collateralIDValue(collateralReserveId_1); + require totalCollateralValueGhost == collateralIDValueBefore + collateralIDValue(collateralReserveId_2) + collateralIDValue(collateralReserveId_3); + + mathint totalCollateralValueBefore = totalCollateralValueGhost; + + liquidationCall(e, collateralReserveId_1, debtReserveId, userLiquidated, debtToCover, receiveShares); + assert totalCollateralValueBefore > collateralIDValueBefore => !deficitReportedFlag; +} + + +/** + * @title More collateral than debt - no report deficit + * @link_property deficit reporting integrity + */ +rule moreCollateralThenDebt_noReportDeficit(uint256 reserveId, address userLiquidated, address liquidator) { + env e; + setup(); + require e.msg.sender == liquidator; + uint256 debtReserveId; + uint256 debtToCover; + bool receiveShares; + require currentTime == e.block.timestamp; + require currentUser == userLiquidated; + + require !deficitReportedFlag; + mathint debtValueBefore = totalDebtValueGhost; + require totalCollateralValueGhost == collateralIDValue(collateralReserveId_1) + collateralIDValue(collateralReserveId_2) + collateralIDValue(collateralReserveId_3); + require totalDebtValueGhost == debtValueReserveId(debtReserveId_1) + debtValueReserveId(debtReserveId_2) + debtValueReserveId(debtReserveId_3); + + mathint totalCollateralValueBefore = totalCollateralValueGhost; + + liquidationCall(e, collateralReserveId_1, debtReserveId, userLiquidated, debtToCover, receiveShares); + assert totalCollateralValueBefore > debtValueBefore => !deficitReportedFlag; +} diff --git a/certora/spec/LiquidationUserIntegrity.spec b/certora/spec/LiquidationUserIntegrity.spec new file mode 100644 index 000000000..93e482d02 --- /dev/null +++ b/certora/spec/LiquidationUserIntegrity.spec @@ -0,0 +1,45 @@ +/** + * @title Liquidation User Integrity Specification + * @notice Verify that only one user's debt changes during liquidation + * @dev This spec ensures that liquidation operations only affect the liquidated user's debt position + */ + +import "./SpokeUserIntegrity.spec"; + +//////////////////////////////////////////////////////////////////////////// +// METHODS // +//////////////////////////////////////////////////////////////////////////// + +methods { + // non deterministic summary for pure functions that don't read or modify state - they only perform calculations on the input parameters. + function LiquidationLogic.calculateLiquidationBonus(uint256, uint256, uint256, uint256) internal returns (uint256) => NONDET; + function LiquidationLogic._validateLiquidationCall(LiquidationLogic.ValidateLiquidationCallParams memory) internal => NONDET; + function LiquidationLogic._calculateDebtToLiquidate(LiquidationLogic.CalculateDebtToLiquidateParams memory) internal returns (uint256, uint256) => NONDET; + function LiquidationLogic._calculateDebtToTargetHealthFactor(LiquidationLogic.CalculateDebtToTargetHealthFactorParams memory) internal returns (uint256) => NONDET; + function LiquidationLogic._evaluateDeficit(bool, bool, uint256, uint256) internal returns (bool) => NONDET; +} + +//////////////////////////////////////////////////////////////////////////// +// RULES // +//////////////////////////////////////////////////////////////////////////// + +/** + * @title Only one user's debt changes during liquidation + * @link_property liquidation call integrity + */ +rule onlyOneUserDebtChanges_liquidationCall(uint256 reserveId, address user1, address user2) { + env e; + + uint256 collateralReserveId; + uint256 debtReserveId; + address userLiquidated; + uint256 debtToCover; + bool receiveShares; + + uint256 beforeDrawnShares1 = spoke._userPositions[user1][reserveId].drawnShares; + uint256 beforeDrawnShares2 = spoke._userPositions[user2][reserveId].drawnShares; + + liquidationCall(e, collateralReserveId, debtReserveId, userLiquidated, debtToCover, receiveShares); + assert beforeDrawnShares1 != spoke._userPositions[user1][reserveId].drawnShares && + beforeDrawnShares2 != spoke._userPositions[user2][reserveId].drawnShares => (user1 == user2 && user1 == userLiquidated); +} diff --git a/certora/spec/Spoke.spec b/certora/spec/Spoke.spec new file mode 100644 index 000000000..e563a2736 --- /dev/null +++ b/certora/spec/Spoke.spec @@ -0,0 +1,402 @@ + +/** + * @title Spoke Contract Specification + * @notice Verify Spoke.sol internal properties and valid state invariants using a symbolic representation of the Hub + * @dev This spec verifies Spoke contract invariants and state properties + * + * To run this spec: + * certoraRun certora/conf/Spoke.conf + */ + +import "./SpokeBase.spec"; +import "./symbolicRepresentation/SymbolicPositionStatus.spec"; +import "./symbolicRepresentation/SymbolicHub.spec"; + +//////////////////////////////////////////////////////////////////////////// +// DEFINITIONS // +//////////////////////////////////////////////////////////////////////////// + +// Definition moved from SpokeBase.spec as it depends on getAssetDrawnIndexCVL from SymbolicHub.spec +definition premiumDebtCVL(address user, uint256 reserveId, env e) returns mathint = + (spoke._userPositions[user][reserveId].premiumShares * getAssetDrawnIndexCVL(spoke._reserves[reserveId].assetId, e)) - spoke._userPositions[user][reserveId].premiumOffsetRay; + +//////////////////////////////////////////////////////////////////////////// +// HOOKS // +//////////////////////////////////////////////////////////////////////////// + +/** + * @notice Assumption: userGhost is the user who is interacting with the Spoke contract. + * It is used to track the user who is interacting with the Spoke contract. + * In SpokeUserIntegrity.spec, we prove that only one user's account is updated and used in a single operation. + */ +// Hooks to track userGhost +hook Sstore _userPositions[KEY address user][KEY uint256 reserveId].suppliedShares uint120 newValue (uint120 oldValue) { + require userGhost == user; +} + +hook Sload uint120 value _userPositions[KEY address user][KEY uint256 reserveId].suppliedShares { + require userGhost == user; +} + +hook Sstore _userPositions[KEY address user][KEY uint256 reserveId].drawnShares uint120 newValue (uint120 oldValue) { + require userGhost == user; +} + +hook Sload uint120 value _userPositions[KEY address user][KEY uint256 reserveId].drawnShares { + require userGhost == user; +} + +//////////////////////////////////////////////////////////////////////////// +// RULES // +//////////////////////////////////////////////////////////////////////////// + +/** + * @title Verify functions that increase collateral or reduce debt + * @notice These functions do not need to perform a health check + * @link_property Health check validity + */ +rule increaseCollateralOrReduceDebtFunctions(method f) filtered {f -> !outOfScopeFunctions(f) && !f.isView && increaseCollateralOrReduceDebtFunctions(f)} { + uint256 reserveId; + uint256 slot; + address user; + env e; + setup(); + requireInvariant validReserveId_single(reserveId); + requireInvariant validReserveId_singleUser(reserveId, user); + requireInvariant drawnSharesRiskEQPremiumShares(user, reserveId); + require userGhost == user; + + // user state before the operation + bool beforePositionStatus_borrowing = isBorrowing[user][reserveId]; + bool beforePositionStatus_usingAsCollateral = isUsingAsCollateral[user][reserveId]; + uint120 beforeUserPosition_drawnShares = spoke._userPositions[user][reserveId].drawnShares; + uint120 beforeUserPosition_premiumShares = spoke._userPositions[user][reserveId].premiumShares; + int200 beforeUserPosition_premiumOffsetRay = spoke._userPositions[user][reserveId].premiumOffsetRay; + uint120 beforeUserPosition_suppliedShares = spoke._userPositions[user][reserveId].suppliedShares; + uint32 beforeUserPosition_dynamicConfigKey = spoke._userPositions[user][reserveId].dynamicConfigKey; + + mathint premiumDebtBefore = premiumDebtCVL(user, reserveId, e); + + // Execute the operation + calldataarg args; + f(e, args); + + // user state after the operation + bool afterPositionStatus_borrowing = isBorrowing[user][reserveId]; + bool afterPositionStatus_usingAsCollateral = isUsingAsCollateral[user][reserveId]; + uint120 afterUserPosition_drawnShares = spoke._userPositions[user][reserveId].drawnShares; + uint120 afterUserPosition_premiumShares = spoke._userPositions[user][reserveId].premiumShares; + int200 afterUserPosition_premiumOffsetRay = spoke._userPositions[user][reserveId].premiumOffsetRay; + uint120 afterUserPosition_suppliedShares = spoke._userPositions[user][reserveId].suppliedShares; + uint32 afterUserPosition_dynamicConfigKey = spoke._userPositions[user][reserveId].dynamicConfigKey; + + mathint premiumDebtAfter = premiumDebtCVL(user, reserveId, e); + + assert beforePositionStatus_borrowing == afterPositionStatus_borrowing || + (beforePositionStatus_borrowing && !afterPositionStatus_borrowing && afterUserPosition_drawnShares == 0 && afterUserPosition_premiumShares == 0 && afterUserPosition_premiumOffsetRay == 0); + + assert beforePositionStatus_usingAsCollateral == afterPositionStatus_usingAsCollateral; + assert beforeUserPosition_drawnShares >= afterUserPosition_drawnShares; + assert premiumDebtBefore >= premiumDebtAfter; + assert beforeUserPosition_suppliedShares <= afterUserPosition_suppliedShares; + assert beforeUserPosition_dynamicConfigKey == afterUserPosition_dynamicConfigKey; +} + +/** + * @title If paused - no change + * @link_property Pause behavior + */ +rule paused_noChange(uint256 reserveId, address user, method f) filtered {f -> !outOfScopeFunctions(f) && !f.isView} { + env e; + calldataarg args; + setup(); + + bool isPaused = pausedGhost; + uint256 drawnSharesBefore = spoke._userPositions[user][reserveId].drawnShares; + uint256 suppliedSharesBefore = spoke._userPositions[user][reserveId].suppliedShares; + + f(e, args); + + assert isPaused => ( + spoke._userPositions[user][reserveId].drawnShares == drawnSharesBefore && + spoke._userPositions[user][reserveId].suppliedShares == suppliedSharesBefore); +} + +/** + * @title If frozen - functions can only reduce debt and reduce collateral + * @link_property Frozen behavior + */ +rule frozen_onlyReduceDebtAndCollateral(uint256 reserveId, address user, method f) filtered {f -> !outOfScopeFunctions(f) && !f.isView} { + env e; + calldataarg args; + setup(); + requireInvariant drawnSharesRiskEQPremiumShares(user, reserveId); + requireInvariant validReserveId_single(reserveId); + requireInvariant validReserveId_singleUser(reserveId, user); + bool isFrozen = frozenGhost; + uint256 drawnSharesBefore = spoke._userPositions[user][reserveId].drawnShares; + uint256 suppliedSharesBefore = spoke._userPositions[user][reserveId].suppliedShares; + mathint premiumDebtBefore = premiumDebtCVL(user, reserveId, e); + + f(e, args); + + mathint premiumDebtAfter = premiumDebtCVL(user, reserveId, e); + assert isFrozen => ( + spoke._userPositions[user][reserveId].drawnShares <= drawnSharesBefore && + premiumDebtAfter <= premiumDebtBefore && + spoke._userPositions[user][reserveId].suppliedShares <= suppliedSharesBefore); +} + +/** + * @title updateUserRiskPremium preserves premium debt + * @link_property valid state + */ +rule updateUserRiskPremium_preservesPremiumDebt(uint256 reserveId, address user) { + env e; + calldataarg args; + setup(); + requireInvariant drawnSharesRiskEQPremiumShares(user, reserveId); + requireInvariant validReserveId_single(reserveId); + + uint256 drawnSharesBefore = spoke._userPositions[user][reserveId].drawnShares; + + mathint premiumDebtBefore = premiumDebtCVL(user, reserveId, e); + spoke.updateUserRiskPremium(e, user); + + mathint premiumDebtAfter = premiumDebtCVL(user, reserveId, e); + + assert spoke._userPositions[user][reserveId].drawnShares == drawnSharesBefore && + premiumDebtAfter == premiumDebtBefore; +} + +/** + * @title Verify that if there is no collateral, then there is no debt + * @link_property valid state + */ +rule noCollateralNoDebt(uint256 reserveIdUsed, address user, method f) filtered {f -> !outOfScopeFunctions(f) && !f.isView} { + env e; + setup(); + require userGhost == user; + requireInvariant validReserveId_single(reserveIdUsed); + requireInvariant validReserveId_singleUser(spoke._reserveCount, user); + requireInvariant dynamicConfigKeyConsistency(reserveIdUsed, user); + + ISpoke.UserAccountData beforeUserAccountData = getUserAccountData(e, user); + uint32 dynamicConfigKey = spoke._reserves[reserveIdUsed].dynamicConfigKey; + uint16 beforeCollateralFactor = spoke._dynamicConfig[reserveIdUsed][dynamicConfigKey].collateralFactor; + require beforeUserAccountData.totalCollateralValue == 0 => beforeUserAccountData.totalDebtValueRay == 0; + + if (f.selector == sig:addDynamicReserveConfig(uint256, ISpoke.DynamicReserveConfig).selector) { + ISpoke.DynamicReserveConfig config; + // assume we are working on reserveIdUsed + addDynamicReserveConfig(e, reserveIdUsed, config); + } else { + calldataarg args; + f(e, args); + } + + ISpoke.UserAccountData afterUserAccountData = getUserAccountData(e, user); + uint32 dynamicConfigKeyAfter = spoke._reserves[reserveIdUsed].dynamicConfigKey; + uint16 afterCollateralFactor = spoke._dynamicConfig[reserveIdUsed][dynamicConfigKeyAfter].collateralFactor; + + require beforeCollateralFactor > 0 => afterCollateralFactor > 0, "rule collateralFactorNotZero"; + assert afterUserAccountData.totalCollateralValue == 0 => afterUserAccountData.totalDebtValueRay == 0; +} + +/** + * @title Verify that the collateral factor is not zero once set to a non-zero value + * @link_property valid state + */ +rule collateralFactorNotZero(uint256 reserveId, address user, method f) filtered {f -> !outOfScopeFunctions(f) && !f.isView} { + env e; + setup(); + requireInvariant dynamicConfigKeyConsistency(reserveId, user); + requireInvariant validReserveId_single(reserveId); + require userGhost == user; + uint32 dynamicConfigKey; + require dynamicConfigKey <= spoke._reserves[reserveId].dynamicConfigKey; + require spoke._dynamicConfig[reserveId][dynamicConfigKey].collateralFactor > 0; + calldataarg args; + f(e, args); + assert spoke._dynamicConfig[reserveId][dynamicConfigKey].collateralFactor > 0; +} + +/** + * @title Verify that the user debt value is deterministic + * @link_property view functions integrity + */ +rule deterministicUserDebtValue(uint256 reserveId, address user) { + env e; + setup(); + require userGhost == user; + uint256 drawnDebt; + uint256 premiumDebt; + (drawnDebt, premiumDebt) = spoke.getUserDebt(e, reserveId, user); + uint256 drawnDebt2; + uint256 premiumDebt2; + (drawnDebt2, premiumDebt2) = spoke.getUserDebt(e, reserveId, user); + assert drawnDebt == drawnDebt2; + assert premiumDebt == premiumDebt2; +} + +//////////////////////////////////////////////////////////////////////////// +// INVARIANTS // +//////////////////////////////////////////////////////////////////////////// + +/** + * @title Verify that borrowing flag is set if and only if there are drawn shares + * @link_property valid state + */ +invariant isBorrowingIFFdrawnShares() + forall uint256 reserveId. forall address user. + spoke._userPositions[user][reserveId].drawnShares > 0 <=> isBorrowing[user][reserveId] + filtered {f -> !outOfScopeFunctions(f)} + +/** + * @title Verify that if there are no drawn shares, then there are no premium shares or offset + * @link_property valid state + */ +invariant drawnSharesZero(address user, uint256 reserveId) + spoke._userPositions[user][reserveId].drawnShares == 0 => (spoke._userPositions[user][reserveId].premiumShares == 0 && spoke._userPositions[user][reserveId].premiumOffsetRay == 0) + filtered {f -> !outOfScopeFunctions(f)} + { + preserved with (env e) { + setup(); + } + } + +/** + * @title Validates reserve state for all users and reserves + * @link_property valid state + */ +invariant validReserveId() + forall uint256 reserveId. forall address user. + // exists + ((reserveId < spoke._reserveCount => + // has underlying and hub + (spoke._reserves[reserveId].underlying != 0 && spoke._reserves[reserveId].hub != 0 && spoke._hubAssetIdToReserveId[spoke._reserves[reserveId].hub][spoke._reserves[reserveId].assetId] == reserveId)) + && + // not exists + (reserveId >= spoke._reserveCount => ( + // has no underlying, hub, assetId + spoke._reserves[reserveId].underlying == 0 && spoke._reserves[reserveId].assetId == 0 && spoke._reserves[reserveId].hub == 0 && spoke._reserves[reserveId].dynamicConfigKey == 0 && spoke._reserves[reserveId].flags == 0 && spoke._reserves[reserveId].collateralRisk == 0 && spoke._dynamicConfig[reserveId][0].collateralFactor == 0 && + // no one borrowed or used as collateral, no supplied or drawn shares, no premium shares or offset + !isBorrowing[user][reserveId] && !isUsingAsCollateral[user][reserveId] && + spoke._userPositions[user][reserveId].suppliedShares == 0 && spoke._userPositions[user][reserveId].drawnShares == 0 && + spoke._userPositions[user][reserveId].premiumShares == 0 && spoke._userPositions[user][reserveId].premiumOffsetRay == 0))) + filtered {f -> !outOfScopeFunctions(f)} + +/** + * @title Validates reserve state for all users for a single reserveId + * @link_property valid state + */ +invariant validReserveId_single(uint256 reserveId) + // exists + (reserveId < spoke._reserveCount => + // has underlying and hub + (spoke._reserves[reserveId].underlying != 0 && spoke._reserves[reserveId].hub != 0 && spoke._hubAssetIdToReserveId[spoke._reserves[reserveId].hub][spoke._reserves[reserveId].assetId] == reserveId)) + && + // not exists + (reserveId >= spoke._reserveCount => ( + // has no underlying, hub, assetId + spoke._reserves[reserveId].underlying == 0 && spoke._reserves[reserveId].assetId == 0 && spoke._reserves[reserveId].hub == 0 && spoke._reserves[reserveId].dynamicConfigKey == 0 && spoke._reserves[reserveId].flags == 0 && spoke._reserves[reserveId].collateralRisk == 0 && spoke._dynamicConfig[reserveId][0].collateralFactor == 0 && + // no one borrowed or used as collateral, no supplied or drawn shares, no premium shares or offset + (forall address user. (!isBorrowing[user][reserveId] && !isUsingAsCollateral[user][reserveId] && + spoke._userPositions[user][reserveId].suppliedShares == 0 && spoke._userPositions[user][reserveId].drawnShares == 0 && + spoke._userPositions[user][reserveId].premiumShares == 0 && spoke._userPositions[user][reserveId].premiumOffsetRay == 0)))) + filtered {f -> !outOfScopeFunctions(f)} + +/** + * @title Validates reserve state for a single user and reserveId + * @link_property valid state + */ +invariant validReserveId_singleUser(uint256 reserveId, address user) + // exists + (reserveId < spoke._reserveCount => + // has underlying and hub + (spoke._reserves[reserveId].underlying != 0 && spoke._reserves[reserveId].hub != 0 && spoke._hubAssetIdToReserveId[spoke._reserves[reserveId].hub][spoke._reserves[reserveId].assetId] == reserveId)) + && + // not exists + (reserveId >= spoke._reserveCount => ( + // has no underlying, hub, assetId + spoke._reserves[reserveId].underlying == 0 && spoke._reserves[reserveId].assetId == 0 && spoke._reserves[reserveId].hub == 0 && spoke._reserves[reserveId].dynamicConfigKey == 0 && spoke._reserves[reserveId].flags == 0 && spoke._reserves[reserveId].collateralRisk == 0 && spoke._dynamicConfig[reserveId][0].collateralFactor == 0 && + // no one borrowed or used as collateral, no supplied or drawn shares, no premium shares or offset + !isBorrowing[user][reserveId] && !isUsingAsCollateral[user][reserveId] && + spoke._userPositions[user][reserveId].suppliedShares == 0 && spoke._userPositions[user][reserveId].drawnShares == 0 && + spoke._userPositions[user][reserveId].premiumShares == 0 && spoke._userPositions[user][reserveId].premiumOffsetRay == 0)) + filtered {f -> !outOfScopeFunctions(f)} + +/** + * @title Hub asset ID to reserve ID integrity + * @link_property valid state + */ +invariant hubAssetIdToReserveIdIntegrity(address hub, uint256 assetId, uint256 reserveId) + (spoke._hubAssetIdToReserveId[hub][assetId] == reserveId && reserveId != 0 => + (spoke._reserves[reserveId].hub == hub && spoke._reserves[reserveId].assetId == assetId && + reserveId < spoke._reserveCount) && + (reserveId < spoke._reserveCount => spoke._hubAssetIdToReserveId[spoke._reserves[reserveId].hub][spoke._reserves[reserveId].assetId] == reserveId)) + filtered {f -> !outOfScopeFunctions(f)} + +/** + * @title Verify that the assetId and hub are unique to a reserveId + * @link_property valid state + */ +invariant uniqueAssetIdPerReserveId(uint256 reserveId, uint256 otherReserveId) + (reserveId < spoke._reserveCount && otherReserveId < spoke._reserveCount && reserveId != otherReserveId) => (spoke._reserves[reserveId].assetId != spoke._reserves[otherReserveId].assetId || spoke._reserves[reserveId].hub != spoke._reserves[otherReserveId].hub) + filtered {f -> !outOfScopeFunctions(f)} + { + preserved { + requireInvariant validReserveId_single(reserveId); + requireInvariant validReserveId_single(otherReserveId); + requireInvariant validReserveId_single(spoke._reserveCount); + } + } + +/** + * @title Verify that the realized premium ray is consistent with the premium shares and drawn index + * @link_property valid state + */ +invariant realizedPremiumRayConsistency(uint256 reserveId, address user, env e) + spoke._userPositions[user][reserveId].premiumOffsetRay <= spoke._userPositions[user][reserveId].premiumShares * getAssetDrawnIndexCVL(spoke._reserves[reserveId].assetId, e) + filtered {f -> !outOfScopeFunctions(f)} + { + preserved with (env eInv) { + require eInv.block.timestamp == e.block.timestamp; + setup(); + requireInvariant validReserveId_single(reserveId); + requireInvariant validReserveId_singleUser(reserveId, user); + } + } + +/** + * @title Verify that the drawn shares are equal to the premium shares multiplied by the risk premium + * @link_property valid state + */ +invariant drawnSharesRiskEQPremiumShares(address user, uint256 reserveId) + ((spoke._userPositions[user][reserveId].drawnShares * spoke._positionStatus[user].riskPremium + PERCENTAGE_FACTOR - 1) / PERCENTAGE_FACTOR == spoke._userPositions[user][reserveId].premiumShares) + filtered {f -> !outOfScopeFunctions(f)} + { + preserved { + setup(); + require spoke._userPositions[user][reserveId].drawnShares > 0 <=> isBorrowing[user][reserveId]; + requireInvariant drawnSharesZero(user, reserveId); + requireInvariant validReserveId_single(reserveId); + // help grounding + require nextBorrowingCVL(spoke._reserveCount) == reserveId; + } + } + +/** + * @title Verify that the dynamic config key is consistent with the reserve dynamic config key + * @link_property valid state + */ +invariant dynamicConfigKeyConsistency(uint256 reserveId, address user) + spoke._userPositions[user][reserveId].dynamicConfigKey <= spoke._reserves[reserveId].dynamicConfigKey + filtered {f -> !outOfScopeFunctions(f)} + { + preserved { + setup(); + requireInvariant validReserveId_single(reserveId); + requireInvariant validReserveId_singleUser(spoke._reserveCount, user); + } + } diff --git a/certora/spec/SpokeBase.spec b/certora/spec/SpokeBase.spec new file mode 100644 index 000000000..2fe251a9e --- /dev/null +++ b/certora/spec/SpokeBase.spec @@ -0,0 +1,71 @@ +/** + * @title Spoke Base Specification + * @notice Base definitions used in all of Spoke spec files + * @dev This spec provides base definitions, ghosts, and setup functions for Spoke contract verification + */ + +import "./SpokeBaseSummaries.spec"; + +using SpokeInstance as spoke; + +//////////////////////////////////////////////////////////////////////////// +// METHODS // +//////////////////////////////////////////////////////////////////////////// + +methods { + function isPositionManager(address user, address positionManager) external returns (bool) envfree; + + function _.paused(ISpoke.ReserveFlags) internal => pausedGhost expect bool; + function _.frozen(ISpoke.ReserveFlags) internal => frozenGhost expect bool; +} + +//////////////////////////////////////////////////////////////////////////// +// GHOSTS // +//////////////////////////////////////////////////////////////////////////// + +persistent ghost bool pausedGhost; +persistent ghost bool frozenGhost; + +//////////////////////////////////////////////////////////////////////////// +// DEFINITIONS // +//////////////////////////////////////////////////////////////////////////// + +definition increaseCollateralOrReduceDebtFunctions(method f) returns bool = + f.selector != sig:withdraw(uint256, uint256, address).selector && + f.selector != sig:liquidationCall(uint256, uint256, address, uint256, bool).selector && + f.selector != sig:borrow(uint256, uint256, address).selector && + f.selector != sig:setUsingAsCollateral(uint256, bool, address).selector && + f.selector != sig:updateUserDynamicConfig(address).selector; + +//////////////////////////////////////////////////////////////////////////// +// FUNCTIONS // +//////////////////////////////////////////////////////////////////////////// + +function setup() { + require spoke._reserveCount < max_uint256; // safe assumption + //requireInvariant validReserveId(); + require forall uint256 reserveId. forall address user. + // exists + (reserveId < spoke._reserveCount => + // has underlying and hub + (spoke._reserves[reserveId].underlying != 0 && spoke._reserves[reserveId].hub != 0 && spoke._hubAssetIdToReserveId[spoke._reserves[reserveId].hub][spoke._reserves[reserveId].assetId] != 0) + && + // not exists + (reserveId >= spoke._reserveCount => ( + // no one borrowed or used as collateral + !isBorrowing[user][reserveId] && !isUsingAsCollateral[user][reserveId] + // no supplied or drawn shares + && spoke._userPositions[user][reserveId].suppliedShares == 0 && spoke._userPositions[user][reserveId].drawnShares == 0 && + // no premium shares or offset + spoke._userPositions[user][reserveId].premiumShares == 0 && spoke._userPositions[user][reserveId].premiumOffsetRay == 0 && + + // has no underlying, hub, assetId + spoke._reserves[reserveId].underlying == 0 && spoke._reserves[reserveId].assetId == 0 && spoke._reserves[reserveId].hub == 0 && spoke._reserves[reserveId].dynamicConfigKey == 0 && spoke._reserves[reserveId].flags == 0 && spoke._reserves[reserveId].collateralRisk == 0))); + + //requireInvariant isBorrowingIFFdrawnShares(); + require forall uint256 reserveId. forall address user. + spoke._userPositions[user][reserveId].drawnShares > 0 <=> isBorrowing[user][reserveId]; + + //requireInvariant drawnSharesZero(address user, uint256 reserveId) + require forall address user. forall uint256 reserveId. spoke._userPositions[user][reserveId].drawnShares == 0 => (spoke._userPositions[user][reserveId].premiumShares == 0 && spoke._userPositions[user][reserveId].premiumOffsetRay == 0); +} diff --git a/certora/spec/SpokeBaseSummaries.spec b/certora/spec/SpokeBaseSummaries.spec new file mode 100644 index 000000000..a2e34ecd9 --- /dev/null +++ b/certora/spec/SpokeBaseSummaries.spec @@ -0,0 +1,77 @@ +/** + * @title Spoke Base Summaries Specification + * @notice Base definitions used in all of Spoke spec files + * @dev This spec provides method summaries and base definitions for Spoke contract verification + */ + +import "./symbolicRepresentation/Math_CVL.spec"; +import "./symbolicRepresentation/ERC20s_CVL.spec"; +import "./common.spec"; + +//////////////////////////////////////////////////////////////////////////// +// METHODS // +//////////////////////////////////////////////////////////////////////////// + +methods { + function _.sortByKey(KeyValueList.List memory array) internal => CVL_sort(array) expect void; + + // view function + function _._hashTypedData(bytes32 structHash) internal => NONDET; + + function _.uncheckedAt(KeyValueList.List memory self, uint256 idx) internal => NONDET; + function _.unsafeMemoryAccess(KeyValueList.List memory self, uint256 idx) internal => NONDET ALL; + + /* assumes a deterministic non-zero price for the reserve pre block.timestamp */ + function _.getReservePrice(uint256 reserveId) external with (env e) => symbolicPrice(reserveId, e.block.timestamp) expect uint256; + + function MathUtils.uncheckedExp(uint256 a, uint256 b) internal returns (uint256) => limitedExp(a, b); + + function _.consumeScheduledOp(address caller, bytes data) external => NONDET ALL; + + // assume setReserveSource is trusted and does not call back into spoke or hub or any of the assets + function _.setReserveSource(uint256 reserveId, address source) external => NONDET ALL; + + function AuthorityUtils.canCallWithDelay( + address authority, + address caller, + address target, + bytes4 selector + ) internal returns (bool, uint32) => NONDET ALL; + + function SignatureChecker.isValidERC1271SignatureNow( + address signer, + bytes32 hash, + bytes memory signature + ) internal returns (bool) => NONDET ALL; + + function SpokeUtils.toValue(uint256 amount, uint256 decimals, uint256 price) internal returns (uint256) => toValueCVL(amount, decimals, price); + + +} + +function CVL_sort(KeyValueList.List array) { + if (array._inner.length > 1) { + require(array._inner[0] < array._inner[1]); + } + if (array._inner.length > 2) { + require(array._inner[1] < array._inner[2]); + } + if (array._inner.length > 3) { + require(array._inner[2] < array._inner[3]); + } +} + + +//deterministic non-zero value for each reserveId and timestamp +//the non-zero assumption is enforced by the oracle +ghost symbolicPrice(uint256 /*reserveId*/, uint256 /*timestamp*/) returns uint256 { + axiom forall uint256 reserveId. forall uint256 timestamp. symbolicPrice(reserveId,timestamp) > 0; +} + +definition outOfScopeFunctions(method f) returns bool = + f.selector == sig:multicall(bytes[]).selector || + f.selector == sig:liquidationCall(uint256, uint256, address, uint256, bool).selector || + f.selector == sig:extSload(bytes32).selector || + f.selector == sig:extSloads(bytes32[]).selector; + + diff --git a/certora/spec/SpokeHealthCheck.spec b/certora/spec/SpokeHealthCheck.spec new file mode 100644 index 000000000..38b5bf7a2 --- /dev/null +++ b/certora/spec/SpokeHealthCheck.spec @@ -0,0 +1,95 @@ +/** + * @title Spoke Health Check Specification + * @notice Verify that the health factor is checked after updates to the user position which can reduce the health factor + * @dev Note that this does not prove the health check logic itself + * + * To run this spec: + * certoraRun certora/conf/SpokeHealthCheck.conf + */ + +import "./SpokeBase.spec"; +import "./symbolicRepresentation/SymbolicPositionStatus.spec"; +import "./symbolicRepresentation/SymbolicHub.spec"; + + + +//////////////////////////////////////////////////////////////////////////// +// METHODS // +//////////////////////////////////////////////////////////////////////////// + +methods { + function Spoke._processUserAccountData(address user, bool refreshConfig) internal returns (ISpoke.UserAccountData memory) => processUserAccountDataCVL(user, refreshConfig); + + // proved in Spoke.spec that this function does not change the user position in rule applyPremiumDelta_preservesUserPosition + function _.applyPremiumDelta(ISpoke.UserPosition storage userPosition, IHubBase.PremiumDelta memory premiumDelta) internal => NONDET; +} + +//////////////////////////////////////////////////////////////////////////// +// GHOSTS // +//////////////////////////////////////////////////////////////////////////// + +persistent ghost mapping(address => uint256) ghostHealthFactor { + init_state axiom forall address user. ghostHealthFactor[user] == 0; +} + +//////////////////////////////////////////////////////////////////////////// +// HOOKS // +//////////////////////////////////////////////////////////////////////////// + +hook Sstore _userPositions[KEY address user][KEY uint256 reserveId].drawnShares uint120 newValue (uint120 oldValue) { + needHealthCheck(user); +} + +hook Sstore _userPositions[KEY address user][KEY uint256 reserveId].suppliedShares uint120 newValue (uint120 oldValue) { + if (isUsingAsCollateral[user][reserveId]) { + needHealthCheck(user); + } +} + +hook Sstore _userPositions[KEY address user][KEY uint256 reserveId].premiumShares uint120 newValue (uint120 oldValue) { + needHealthCheck(user); +} + +hook Sstore _userPositions[KEY address user][KEY uint256 reserveId].premiumOffsetRay int200 newValue (int200 oldValue) { + needHealthCheck(user); +} + +hook Sstore _positionStatus[KEY address user].map[KEY uint256 slot] uint256 value { + needHealthCheck(user); +} + +function needHealthCheck(address user) { + uint256 newHealthFactor; + ghostHealthFactor[user] = newHealthFactor; +} + +function processUserAccountDataCVL(address user, bool refreshConfig) returns (ISpoke.UserAccountData) { + ISpoke.UserAccountData userAccountData; + require userAccountData.healthFactor == ghostHealthFactor[user]; + require userAccountData.activeCollateralCount <= reserveCountGhost; + return userAccountData; +} + +/** + * @title Verify that the health factor is checked after updates to the user position which can reduce the health factor + * @link_property Health check validity + */ +rule userHealthChecked(method f) filtered {f -> !outOfScopeFunctions(f) && !increaseCollateralOrReduceDebtFunctions(f)} { + uint256 HEALTH_FACTOR_LIQUIDATION_THRESHOLD = 10 ^ 18; + // Get the user address from the method call + address user; + env e; + setup(); + require userGhost == user; + + // Check health factor before the operation + require ghostHealthFactor[user] >= HEALTH_FACTOR_LIQUIDATION_THRESHOLD; + + // Execute the operation + calldataarg args; + f(e, args); + + // If the operation succeeded, check health factor after + assert ghostHealthFactor[user] >= HEALTH_FACTOR_LIQUIDATION_THRESHOLD; +} + diff --git a/certora/spec/SpokeHealthFactor.spec b/certora/spec/SpokeHealthFactor.spec new file mode 100644 index 000000000..ddb3ef30f --- /dev/null +++ b/certora/spec/SpokeHealthFactor.spec @@ -0,0 +1,314 @@ +/** + * @title Spoke Health Factor Specification + * @notice Verify that the health factor is above threshold after any operation + * @dev Symbolic representation of the total collateral value and total debt value. + * The health factor is calculated as the total collateral value divided by the total debt value. + * + * To run this spec: + * certoraRun certora/conf/SpokeHealthCheck_take2.conf + */ + +import "./SpokeBaseSummaries.spec"; +import "./symbolicRepresentation/SymbolicHub.spec"; + +using SpokeInstance as spoke; + +//////////////////////////////////////////////////////////////////////////// +// METHODS // +//////////////////////////////////////////////////////////////////////////// + +methods { + // Position status methods are imported from SymbolicPositionStatus.spec + function Spoke._processUserAccountData(address user, bool refreshConfig) internal returns (ISpoke.UserAccountData memory) => processUserAccountDataCVL(user, refreshConfig); + + function _.setBorrowing(ISpoke.PositionStatus storage positionStatus, uint256 reserveId, bool borrowing) internal => NONDET; + + function _.setUsingAsCollateral(ISpoke.PositionStatus storage positionStatus, uint256 reserveId, bool usingAsCollateral) internal => setUsingAsCollateralCVL_updateTotals(reserveId, usingAsCollateral) expect void; + + function _.isUsingAsCollateralOrBorrowing(ISpoke.PositionStatus storage positionStatus, uint256 reserveId) internal => NONDET; + + function _.isBorrowing(ISpoke.PositionStatus storage positionStatus, uint256 reserveId) internal => NONDET; + + function _.isUsingAsCollateral(ISpoke.PositionStatus storage positionStatus, uint256 reserveId) internal => isUsingAsCollateralCVL(reserveId) expect bool; + + function _.collateralCount(ISpoke.PositionStatus storage positionStatus, uint256 reserveCount) internal => NONDET; + + function _.next(ISpoke.PositionStatus storage positionStatus, uint256 startReserveId) internal => NONDET; + + function _.nextBorrowing(ISpoke.PositionStatus storage positionStatus, uint256 startReserveId) internal => nextBorrowingCVL(startReserveId) expect uint256; + + function _.nextCollateral(ISpoke.PositionStatus storage positionStatus, uint256 startReserveId) internal => NONDET; + + // proved in Spoke.spec : updateUserRiskPremium_preservesPremiumDebt that this function preserves debt + function _.notifyRiskPremiumUpdate(address user, uint256 newRiskPremium) internal => NONDET; +} + +//////////////////////////////////////////////////////////////////////////// +// GHOSTS // +//////////////////////////////////////////////////////////////////////////// + +persistent ghost mapping(mathint /* totalCollateralValue */ => mapping(mathint /* totalDebtValue */ => uint256 /* healthFactor */)) ghostHealthFactor { + init_state axiom forall mathint totalCollateralValue. forall mathint totalDebtValue. ghostHealthFactor[totalCollateralValue][totalDebtValue] == 0; + axiom forall mathint totalCollateralValue. forall mathint totalDebtValue. totalDebtValue > 0 ? ghostHealthFactor[totalCollateralValue][totalDebtValue] == totalCollateralValue / totalDebtValue : ghostHealthFactor[totalCollateralValue][totalDebtValue] == max_uint256; +} + +ghost mathint totalCollateralValueGhost; + +ghost mathint totalDebtValueGhost; + +ghost uint256 currentTime; + +ghost address currentUser; + +ghost uint256 debtReserveId_1; + +ghost uint256 debtReserveId_2; + +ghost uint256 debtReserveId_3; + +ghost uint256 collateralReserveId_1; + +ghost uint256 collateralReserveId_2; + +ghost uint256 collateralReserveId_3; + +ghost mapping(uint256 /*reserveId*/ => bool /*usingAsCollateral*/) isUsingAsCollateral { + init_state axiom forall uint256 reserveId. !isUsingAsCollateral[reserveId]; +} + +ghost mathint activeCollateralCountGhost; + +//////////////////////////////////////////////////////////////////////////// +// DEFINITIONS // +//////////////////////////////////////////////////////////////////////////// + +definition knownDebtReserveIds(uint256 reserveId) returns bool = + reserveId == debtReserveId_1 || reserveId == debtReserveId_2 || reserveId == debtReserveId_3; + +definition knownCollateralReserveIds(uint256 reserveId) returns bool = + reserveId == collateralReserveId_1 || reserveId == collateralReserveId_2 || reserveId == collateralReserveId_3; + +definition HEALTH_FACTOR_LIQUIDATION_THRESHOLD() returns uint256 = 10 ^ 18; + +// definition of function that should revert if the health factor is below the threshold +definition belowThresholdRevertingFunctions(method f) returns bool = + f.selector == sig:updateUserDynamicConfig(address).selector || + f.selector == sig:borrow(uint256, uint256, address).selector; + +//////////////////////////////////////////////////////////////////////////// +// FUNCTIONS // +//////////////////////////////////////////////////////////////////////////// + +function setUsingAsCollateralCVL_updateTotals(uint256 reserveId, bool usingAsCollateral) { + uint256 assetId = spoke._reserves[reserveId].assetId; + require knownCollateralReserveIds(reserveId); + mathint currValue = collateralIDValue(reserveId); + if (isUsingAsCollateral[reserveId] && !usingAsCollateral) { + totalCollateralValueGhost = totalCollateralValueGhost - currValue; + } else if (!isUsingAsCollateral[reserveId] && usingAsCollateral) { + totalCollateralValueGhost = totalCollateralValueGhost + currValue; + } + isUsingAsCollateral[reserveId] = usingAsCollateral; +} + +function isUsingAsCollateralCVL(uint256 reserveId) returns (bool) { + return isUsingAsCollateral[reserveId]; +} + +function processUserAccountDataCVL(address user, bool refreshConfig) returns (ISpoke.UserAccountData) { + ISpoke.UserAccountData userAccountData; + require userAccountData.healthFactor == ghostHealthFactor[totalCollateralValueGhost][totalDebtValueGhost]; + activeCollateralCountGhost = 0; + if (collateralIDValue(collateralReserveId_1) > 0) { + activeCollateralCountGhost = activeCollateralCountGhost + 1; + } + if (collateralIDValue(collateralReserveId_2) > 0) { + activeCollateralCountGhost = activeCollateralCountGhost + 1; + } + if (collateralIDValue(collateralReserveId_3) > 0) { + activeCollateralCountGhost = activeCollateralCountGhost + 1; + } + require userAccountData.activeCollateralCount == activeCollateralCountGhost; + return userAccountData; +} + +function collateralIDValue(uint256 reserveId) returns (mathint) { + uint256 assetId = spoke._reserves[reserveId].assetId; + return spoke._userPositions[currentUser][reserveId].suppliedShares * shareToAssetsRatio[assetId][currentTime] * symbolicPrice(reserveId, currentTime); +} + +function nextBorrowingCVL(uint256 startReserveId) returns (uint256) { + if (startReserveId > collateralReserveId_1 && collateralIDValue(collateralReserveId_1) > 0) { + return collateralReserveId_1; + } + if (startReserveId > collateralReserveId_2 && collateralIDValue(collateralReserveId_2) > 0) { + return collateralReserveId_2; + } + if (startReserveId > collateralReserveId_3 && collateralIDValue(collateralReserveId_3) > 0) { + return collateralReserveId_3; + } + return max_uint256; +} + +function validReserveId_singleUser(uint256 reserveId) { + require + (reserveId < spoke._reserveCount => + // has underlying and hub + (spoke._reserves[reserveId].underlying != 0 && spoke._reserves[reserveId].hub != 0 && spoke._hubAssetIdToReserveId[spoke._reserves[reserveId].hub][spoke._reserves[reserveId].assetId] != 0)) + && + // not exists + (reserveId >= spoke._reserveCount => + // has no underlying, hub, assetId + spoke._reserves[reserveId].underlying == 0 && spoke._reserves[reserveId].assetId == 0 && spoke._reserves[reserveId].hub == 0 && spoke._reserves[reserveId].dynamicConfigKey == 0 && spoke._reserves[reserveId].flags == 0 && spoke._reserves[reserveId].collateralRisk == 0 && + spoke._dynamicConfig[reserveId][0].collateralFactor == 0 && + // not used as collateral + !isUsingAsCollateral[reserveId] && + // no supplied or drawn shares + spoke._userPositions[currentUser][reserveId].suppliedShares == 0 && spoke._userPositions[currentUser][reserveId].drawnShares == 0 && + // no premium shares or offset + spoke._userPositions[currentUser][reserveId].premiumShares == 0 && spoke._userPositions[currentUser][reserveId].premiumOffsetRay == 0); +} + +function drawnSharesRiskLEPremiumShares(uint256 reserveId) { + require (spoke._userPositions[currentUser][reserveId].drawnShares * spoke._positionStatus[currentUser].riskPremium + PERCENTAGE_FACTOR - 1) / PERCENTAGE_FACTOR == spoke._userPositions[currentUser][reserveId].premiumShares; +} + +function drawnSharesZero(uint256 reserveId) { + require spoke._userPositions[currentUser][reserveId].drawnShares == 0 => (spoke._userPositions[currentUser][reserveId].premiumShares == 0 && spoke._userPositions[currentUser][reserveId].premiumOffsetRay == 0); +} + +function setUpForOne(uint256 reserveID) { + drawnSharesRiskLEPremiumShares(reserveID); + drawnSharesZero(reserveID); + validReserveId_singleUser(reserveID); +} + +function setup() { + setUpForOne(debtReserveId_1); + setUpForOne(debtReserveId_2); + setUpForOne(debtReserveId_3); +} + +//////////////////////////////////////////////////////////////////////////// +// HOOKS // +//////////////////////////////////////////////////////////////////////////// + +hook Sstore _userPositions[KEY address user][KEY uint256 reserveId].drawnShares uint120 newValue (uint120 oldValue) { + require knownDebtReserveIds(reserveId); + uint256 assetId = spoke._reserves[reserveId].assetId; + totalDebtValueGhost = totalDebtValueGhost + ( + (newValue - oldValue) * indexOfAssetPerBlock[assetId][currentTime] * symbolicPrice(reserveId, currentTime)); +} + +hook Sload uint120 value _userPositions[KEY address user][KEY uint256 reserveId].drawnShares { + require knownDebtReserveIds(reserveId); + uint256 assetId = spoke._reserves[reserveId].assetId; + require totalDebtValueGhost >= + value * indexOfAssetPerBlock[assetId][currentTime] * symbolicPrice(reserveId, currentTime); +} + +hook Sstore _userPositions[KEY address user][KEY uint256 reserveId].suppliedShares uint120 newValue (uint120 oldValue) { + require knownCollateralReserveIds(reserveId); + if (isUsingAsCollateral[reserveId]) { + uint256 assetId = spoke._reserves[reserveId].assetId; + totalCollateralValueGhost = totalCollateralValueGhost + ( + (newValue - oldValue) * shareToAssetsRatio[assetId][currentTime] * symbolicPrice(reserveId, currentTime)); + } +} + +hook Sload uint120 value _userPositions[KEY address user][KEY uint256 reserveId].suppliedShares { + require knownCollateralReserveIds(reserveId); + if (isUsingAsCollateral[reserveId]) { + uint256 assetId = spoke._reserves[reserveId].assetId; + require totalCollateralValueGhost >= + value * shareToAssetsRatio[assetId][currentTime] * symbolicPrice(reserveId, currentTime); + } +} + +hook Sstore _userPositions[KEY address user][KEY uint256 reserveId].premiumShares uint120 newValue (uint120 oldValue) { + require knownDebtReserveIds(reserveId); + uint256 assetId = spoke._reserves[reserveId].assetId; + totalDebtValueGhost = totalDebtValueGhost + ( + (newValue - oldValue) * indexOfAssetPerBlock[assetId][currentTime] * symbolicPrice(reserveId, currentTime)); +} + +hook Sload uint120 value _userPositions[KEY address user][KEY uint256 reserveId].premiumShares { + require knownDebtReserveIds(reserveId); + uint256 assetId = spoke._reserves[reserveId].assetId; + require totalDebtValueGhost >= + (value * indexOfAssetPerBlock[assetId][currentTime] - spoke._userPositions[currentUser][reserveId].premiumOffsetRay) * symbolicPrice(reserveId, currentTime); +} + +hook Sstore _userPositions[KEY address user][KEY uint256 reserveId].premiumOffsetRay int200 newValue (int200 oldValue) { + require knownDebtReserveIds(reserveId); + totalDebtValueGhost = totalDebtValueGhost - ((newValue - oldValue) * symbolicPrice(reserveId, currentTime)); +} + +hook Sload int200 value _userPositions[KEY address user][KEY uint256 reserveId].premiumOffsetRay { + require knownDebtReserveIds(reserveId); + uint256 assetId = spoke._reserves[reserveId].assetId; + require totalDebtValueGhost >= + (value * indexOfAssetPerBlock[assetId][currentTime] - value) * symbolicPrice(reserveId, currentTime); +} + +//////////////////////////////////////////////////////////////////////////// +// RULES // +//////////////////////////////////////////////////////////////////////////// + +/** + * @title Functions revert when health factor is below threshold + * @link_property Health check validity + */ +rule belowThresholdReverting(method f) filtered {f -> belowThresholdRevertingFunctions(f)} { + env e; + calldataarg args; + setup(); + require currentTime == e.block.timestamp; + require totalCollateralValueGhost >= 0 && totalDebtValueGhost >= 0; + require ghostHealthFactor[totalCollateralValueGhost][totalDebtValueGhost] < HEALTH_FACTOR_LIQUIDATION_THRESHOLD(); + f@withrevert(e, args); + assert lastReverted; +} + +/** + * @title Verify that the health factor can only increase if the health factor is below the threshold + * @link_property Health check validity + */ +rule userHealthBelowThresholdCanOnlyIncreaseHealthFactor(method f) filtered {f -> !f.isView && !outOfScopeFunctions(f) && !belowThresholdRevertingFunctions(f)} { + env e; + setup(); + require currentTime == e.block.timestamp; + require totalCollateralValueGhost >= 0 && totalDebtValueGhost >= 0; + uint256 healthFactorBefore = ghostHealthFactor[totalCollateralValueGhost][totalDebtValueGhost]; + require ghostHealthFactor[totalCollateralValueGhost][totalDebtValueGhost] < HEALTH_FACTOR_LIQUIDATION_THRESHOLD(); + + calldataarg args; + if (f.selector == sig:setUsingAsCollateral(uint256, bool, address).selector) { + uint256 reserveId; + bool usingAsCollateral; + setUsingAsCollateral(e, reserveId, usingAsCollateral, currentUser); + } + f(e, args); + + require totalCollateralValueGhost >= 0 && totalDebtValueGhost >= 0; + assert healthFactorBefore <= ghostHealthFactor[totalCollateralValueGhost][totalDebtValueGhost]; +} + +/** + * @title Verify that the health factor is above the threshold after any operation + * @notice Excludes setUsingAsCollateral and borrow functions due to timeouts + * @link_property Health check validity + */ +rule userHealthAboveThreshold(method f) filtered {f -> !f.isView && !outOfScopeFunctions(f) && f.selector != sig:setUsingAsCollateral(uint256, bool, address).selector && f.selector != sig:borrow(uint256, uint256, address).selector} { + env e; + setup(); + require currentTime == e.block.timestamp; + require totalCollateralValueGhost >= 0 && totalDebtValueGhost >= 0; + require ghostHealthFactor[totalCollateralValueGhost][totalDebtValueGhost] >= HEALTH_FACTOR_LIQUIDATION_THRESHOLD(); + + calldataarg args; + f(e, args); + + require totalCollateralValueGhost >= 0 && totalDebtValueGhost >= 0; + assert ghostHealthFactor[totalCollateralValueGhost][totalDebtValueGhost] >= HEALTH_FACTOR_LIQUIDATION_THRESHOLD(); +} diff --git a/certora/spec/SpokeHubIntegrity.spec b/certora/spec/SpokeHubIntegrity.spec new file mode 100644 index 000000000..b56dc860d --- /dev/null +++ b/certora/spec/SpokeHubIntegrity.spec @@ -0,0 +1,264 @@ +/** + * @title Spoke Hub Integrity Specification + * @notice Verify the integrity of the Spoke contract related to Hub + * @dev Assumption: the Hub is the specific implementation in Hub.sol + * + * To run this spec: + * certoraRun certora/conf/SpokeWithHub.conf + */ + +import "./SpokeBase.spec"; +import "./symbolicRepresentation/SymbolicPositionStatus.spec"; +import "./HubValidState.spec"; + +// Note: 'spoke' alias is declared in SpokeBase.spec +// Note: 'hub' alias is declared in HubValidState.spec + + + +//////////////////////////////////////////////////////////////////////////// +// GHOST VARIABLES // +//////////////////////////////////////////////////////////////////////////// + +/// Sum of all user supplied shares per reserveId +ghost mapping(uint256 /*reserveId*/ => mathint /*source*/) sumUserSuppliedSharesPerReserveId { + init_state axiom forall uint256 reserveId. sumUserSuppliedSharesPerReserveId[reserveId] == 0; +} + +/// Sum of all user drawn shares per reserveId +ghost mapping(uint256 /*reserveId*/ => mathint /*source*/) sumUserDrawnSharesPerReserveId { + init_state axiom forall uint256 reserveId. sumUserDrawnSharesPerReserveId[reserveId] == 0; +} + +/// Sum of all user premium shares per reserveId +ghost mapping(uint256 /*reserveId*/ => mathint /*source*/) sumUserPremiumSharesPerReserveId { + init_state axiom forall uint256 reserveId. sumUserPremiumSharesPerReserveId[reserveId] == 0; +} + +/// Sum of all user premium offset per reserveId +ghost mapping(uint256 /*reserveId*/ => mathint /*source*/) sumUserPremiumOffsetPerReserveId { + init_state axiom forall uint256 reserveId. sumUserPremiumOffsetPerReserveId[reserveId] == 0; +} + +//////////////////////////////////////////////////////////////////////////// +// HOOKS // +//////////////////////////////////////////////////////////////////////////// + +// Hook on sstore and sload to synchronize the ghost with storage changes +hook Sstore _userPositions[KEY address user][KEY uint256 reserveId].suppliedShares uint120 newValue (uint120 oldValue) { + sumUserSuppliedSharesPerReserveId[reserveId] = sumUserSuppliedSharesPerReserveId[reserveId] + newValue - oldValue; +} + +hook Sload uint120 value _userPositions[KEY address user][KEY uint256 reserveId].suppliedShares { + require sumUserSuppliedSharesPerReserveId[reserveId] >= value; +} + +hook Sstore _userPositions[KEY address user][KEY uint256 reserveId].drawnShares uint120 newValue (uint120 oldValue) { + sumUserDrawnSharesPerReserveId[reserveId] = sumUserDrawnSharesPerReserveId[reserveId] + newValue - oldValue; +} + +hook Sload uint120 value _userPositions[KEY address user][KEY uint256 reserveId].drawnShares { + require sumUserDrawnSharesPerReserveId[reserveId] >= value; +} + +hook Sstore _userPositions[KEY address user][KEY uint256 reserveId].premiumShares uint120 newValue (uint120 oldValue) { + sumUserPremiumSharesPerReserveId[reserveId] = sumUserPremiumSharesPerReserveId[reserveId] + newValue - oldValue; +} + +hook Sload uint120 value _userPositions[KEY address user][KEY uint256 reserveId].premiumShares { + require sumUserPremiumSharesPerReserveId[reserveId] >= value; +} + +hook Sstore _userPositions[KEY address user][KEY uint256 reserveId].premiumOffsetRay int200 newValue (int200 oldValue) { + sumUserPremiumOffsetPerReserveId[reserveId] = sumUserPremiumOffsetPerReserveId[reserveId] + to_mathint(newValue) - to_mathint(oldValue); +} + +//////////////////////////////////////////////////////////////////////////// +// INVARIANTS // +//////////////////////////////////////////////////////////////////////////// + + + +/** + * @title Verify that the sum of users' drawn shares for a reserveId are consistent with the Hub drawn Shares for the Spoke + * @link_property Spoke Hub consistency + */ +invariant userDrawnShareConsistency(uint256 reserveId) + sumUserDrawnSharesPerReserveId[reserveId] == hub._spokes[spoke._reserves[reserveId].assetId][spoke].drawnShares && + ( reserveId >= spoke._reserveCount => + sumUserDrawnSharesPerReserveId[reserveId] == 0 + ) + filtered {f -> !outOfScopeFunctions(f)} + { + preserved with (env e) { + require e.msg.sender != spoke; + safeAssumptions(); + } + preserved constructor() { + require hub._spokes[spoke._reserves[reserveId].assetId][spoke].drawnShares == 0; + } + preserved addReserve(address hub_, uint256 assetId_arg, address priceSource, ISpoke.ReserveConfig config, ISpoke.DynamicReserveConfig dynamicConfig) with (env e) { + require hub_ == hub && assetId_arg == spoke._reserves[reserveId].assetId; + safeAssumptions(); + } + preserved repay(uint256 otherReserveId, uint256 amount, address onBehalfOf) with (env e) { + safeAssumptions(); + //proved in spoke.spec : uniqueAssetIdPerReserveId + require (reserveId < spoke._reserveCount && otherReserveId < spoke._reserveCount && reserveId != otherReserveId ) => (spoke._reserves[reserveId].assetId != spoke._reserves[otherReserveId].assetId ); + } + preserved borrow(uint256 otherReserveId, uint256 amount, address onBehalfOf) with (env e) { + safeAssumptions(); + //proved in spoke.spec : uniqueAssetIdPerReserveId + require (reserveId < spoke._reserveCount && otherReserveId < spoke._reserveCount && reserveId != otherReserveId ) => (spoke._reserves[reserveId].assetId != spoke._reserves[otherReserveId].assetId ); + } + + } + +/** + * @title Verify that the sum of users' premium shares for a reserveId are consistent with the Hub premium shares for the Spoke + * @link_property Spoke Hub consistency + */ +invariant userPremiumShareConsistency(uint256 reserveId) + sumUserPremiumSharesPerReserveId[reserveId] == hub._spokes[spoke._reserves[reserveId].assetId][spoke].premiumShares && + ( reserveId >= spoke._reserveCount => + sumUserPremiumSharesPerReserveId[reserveId] == 0 + ) + filtered {f -> !outOfScopeFunctions(f)} + { + preserved with (env e) { + require e.msg.sender != spoke; + safeAssumptions(); + } + preserved constructor() { + require hub._spokes[spoke._reserves[reserveId].assetId][spoke].premiumShares == 0; + } + preserved addReserve(address hub_, uint256 assetId_arg, address priceSource, ISpoke.ReserveConfig config, ISpoke.DynamicReserveConfig dynamicConfig) with (env e) { + require hub_ == hub && assetId_arg == spoke._reserves[reserveId].assetId; + safeAssumptions(); + } + } + +/** + * @title Verify that the sum of users' premium offset for a reserveId are consistent with the Hub premium offset for the Spoke + * @link_property Spoke Hub consistency + */ +invariant userPremiumOffsetConsistency(uint256 reserveId) + sumUserPremiumOffsetPerReserveId[reserveId] == hub._spokes[spoke._reserves[reserveId].assetId][spoke].premiumOffsetRay && + ( reserveId >= spoke._reserveCount => + sumUserPremiumOffsetPerReserveId[reserveId] == 0 + ) + filtered {f -> !outOfScopeFunctions(f)} + { + preserved with (env e) { + require e.msg.sender != spoke; + safeAssumptions(); + } + preserved constructor() { + require hub._spokes[spoke._reserves[reserveId].assetId][spoke].premiumOffsetRay == 0; + } + preserved addReserve(address hub_, uint256 assetId_arg, address priceSource, ISpoke.ReserveConfig config, ISpoke.DynamicReserveConfig dynamicConfig) with (env e) { + require hub_ == hub && assetId_arg == spoke._reserves[reserveId].assetId; + safeAssumptions(); + } + } + + +/** + * @title Verify that the sum of users' supplied shares for a reserveId are consistent with the Hub supplied shares for the Spoke + * @link_property Spoke Hub consistency + */ +invariant userSuppliedShareConsistency(uint256 reserveId, uint256 assetId_) + sumUserSuppliedSharesPerReserveId[reserveId] <= hub._spokes[spoke._reserves[reserveId].assetId][spoke].addedShares + && + ( reserveId >= spoke._reserveCount => + sumUserSuppliedSharesPerReserveId[reserveId] == 0 + ) + filtered {f -> !outOfScopeFunctions(f)} + { + preserved with (env e) { + require e.msg.sender != spoke; + safeAssumptions(); + require hub._assets[spoke._reserves[reserveId].assetId].feeReceiver != spoke; + require hub._assets[assetId_].feeReceiver != spoke; + } + preserved addReserve(address hub_, uint256 assetId_arg, address priceSource, ISpoke.ReserveConfig config, ISpoke.DynamicReserveConfig dynamicConfig) with (env e) { + require hub_ == hub && assetId_arg == assetId_; + safeAssumptions(); + require hub._assets[spoke._reserves[reserveId].assetId].feeReceiver != spoke; + require hub._assets[assetId_].feeReceiver != spoke; + } + } + + +/** + * @title Verify that the repay function reduces the debt of a user + * @link_property Spoke Hub consistency + */ +rule repay_debtDecrease(uint256 reserveId, uint256 amount, address user) { + env e; + safeAssumptions(); + requireAllInvariants(spoke._reserves[reserveId].assetId, e); + requireInvariant premiumOffset_Integrity(spoke._reserves[reserveId].assetId, spoke); + + require userGhost == user; + + uint120 beforeUserPosition_drawnShares = spoke._userPositions[user][reserveId].drawnShares; + + mathint premiumDebtBefore = (spoke._userPositions[user][reserveId].premiumShares * cachedIndex)- spoke._userPositions[user][reserveId].premiumOffsetRay; + spoke.repay(e, reserveId, amount, user); + + uint120 afterUserPosition_drawnShares = spoke._userPositions[user][reserveId].drawnShares; + + mathint premiumDebtAfter = (spoke._userPositions[user][reserveId].premiumShares * cachedIndex)- spoke._userPositions[user][reserveId].premiumOffsetRay; + + + assert premiumDebtBefore >= premiumDebtAfter; + assert beforeUserPosition_drawnShares >= afterUserPosition_drawnShares; + +} + +/** + * @title Verify that if the user has no drawn shares, then there are no premium shares or offset. + * @link_property Spoke Hub consistency + */ +rule repay_zeroDebt(uint256 reserveId, uint256 amount, address user) { + env e; + require spoke._userPositions[user][reserveId].drawnShares == 0 => ( spoke._userPositions[user][reserveId].premiumShares == 0 && spoke._userPositions[user][reserveId].premiumOffsetRay == 0); + + spoke.repay(e, reserveId, amount, user); + + assert spoke._userPositions[user][reserveId].drawnShares == 0 => ( spoke._userPositions[user][reserveId].premiumShares == 0 && spoke._userPositions[user][reserveId].premiumOffsetRay == 0); +} + +function safeAssumptions() { + // rules proved in spoke.spec and assuming one hub + require forall uint256 reserveId. forall uint256 otherReserveId. + (reserveId != otherReserveId ) => spoke._reserves[reserveId].assetId != spoke._reserves[otherReserveId].assetId ; + + // a reserveId that exists has underlying and hub + require forall uint256 reserveId. (reserveId < spoke._reserveCount => + // has underlying and hub + (spoke._reserves[reserveId].underlying != 0 && spoke._reserves[reserveId].hub == hub && spoke._hubAssetIdToReserveId[spoke._reserves[reserveId].hub][spoke._reserves[reserveId].assetId] != 0 )); + + // a reserveId that does not exist has no underlying, hub, assetId + require forall uint256 reserveId. reserveId >= spoke._reserveCount => ( + // has no underlying, hub, assetId + spoke._reserves[reserveId].underlying == 0 && spoke._reserves[reserveId].assetId == 0 && spoke._reserves[reserveId].hub == 0 && spoke._reserves[reserveId].dynamicConfigKey == 0); + + // based on hubValidState.spec : validAssetId + require forall uint256 assetId. assetId >= hub._assetCount => ( + hub._assets[assetId].addedShares == 0 && + hub._assets[assetId].drawnShares == 0 && + hub._assets[assetId].premiumShares == 0 && + hub._assets[assetId].premiumOffsetRay == 0 && + hub._assets[assetId].drawnIndex == 0 && + hub._assets[assetId].drawnRate == 0 && + hub._assets[assetId].lastUpdateTimestamp == 0 && + hub._spokes[assetId][spoke].addedShares == 0 && + hub._spokes[assetId][spoke].drawnShares == 0 && + hub._spokes[assetId][spoke].premiumShares == 0 && + hub._spokes[assetId][spoke].premiumOffsetRay == 0 && + !hub._spokes[assetId][spoke].active + ); + +} \ No newline at end of file diff --git a/certora/spec/SpokeIntegrity.spec b/certora/spec/SpokeIntegrity.spec new file mode 100644 index 000000000..fe5b330fc --- /dev/null +++ b/certora/spec/SpokeIntegrity.spec @@ -0,0 +1,205 @@ +/** +Spoke verification integrity rules that verify that change is consistent. + +To run this spec file: + certoraRun certora/conf/SpokeIntegrity.conf +**/ + +import "./SpokeBase.spec"; +import "./symbolicRepresentation/SymbolicPositionStatus.spec"; +import "./symbolicRepresentation/SymbolicHub.spec"; + + +definition premiumDebtCVL(address user, uint256 reserveId, env e) returns mathint = +(spoke._userPositions[user][reserveId].premiumShares * getAssetDrawnIndexCVL(spoke._reserves[reserveId].assetId, e)) - spoke._userPositions[user][reserveId].premiumOffsetRay; + +/** + * @title Supply operation increases user's supplied shares and transfers tokens from user + * @link_property Spoke integrity + */ +rule nothingForZero_supply(uint256 reserveId, uint256 amount, address onBehalfOf) { + env e; + setup(); + address underlying = spoke._reserves[reserveId].underlying; + //same underlying as in hub + require underlying == assetUnderlying[spoke._reserves[reserveId].assetId]; + + uint256 suppliedSharesBefore = spoke._userPositions[onBehalfOf][reserveId].suppliedShares; + uint256 userBalanceBefore = tokenBalanceOf(underlying, e.msg.sender); + + supply(e, reserveId, amount, onBehalfOf); + + assert spoke._userPositions[onBehalfOf][reserveId].suppliedShares > suppliedSharesBefore; + assert e.msg.sender != spoke._reserves[reserveId].hub => tokenBalanceOf(underlying, e.msg.sender) < userBalanceBefore; +} + +/** + * @title Withdraw operation decreases user's supplied shares and transfers tokens to user + * @link_property Spoke integrity + */ +rule nothingForZero_withdraw(uint256 reserveId, uint256 amount, address onBehalfOf) { + env e; + setup(); + address underlying = spoke._reserves[reserveId].underlying; + //same underlying as in hub + require underlying == assetUnderlying[spoke._reserves[reserveId].assetId]; + + uint256 suppliedSharesBefore = spoke._userPositions[onBehalfOf][reserveId].suppliedShares; + uint256 userBalanceBefore = tokenBalanceOf(underlying, e.msg.sender); + + withdraw(e, reserveId, amount, onBehalfOf); + + assert spoke._userPositions[onBehalfOf][reserveId].suppliedShares < suppliedSharesBefore; + assert e.msg.sender != spoke._reserves[reserveId].hub => tokenBalanceOf(underlying, e.msg.sender) > userBalanceBefore; +} + +/** + * @title Borrow operation increases user's drawn shares and transfers tokens to user + * @link_property Spoke integrity + */ +rule nothingForZero_borrow(uint256 reserveId, uint256 amount, address onBehalfOf) { + env e; + setup(); + address underlying = spoke._reserves[reserveId].underlying; + //same underlying as in hub + require underlying == assetUnderlying[spoke._reserves[reserveId].assetId]; + + uint256 drawnSharesBefore = spoke._userPositions[onBehalfOf][reserveId].drawnShares; + uint256 userBalanceBefore = tokenBalanceOf(underlying, e.msg.sender); + + borrow(e, reserveId, amount, onBehalfOf); + + assert spoke._userPositions[onBehalfOf][reserveId].drawnShares > drawnSharesBefore; + assert e.msg.sender != spoke._reserves[reserveId].hub => tokenBalanceOf(underlying, e.msg.sender) > userBalanceBefore; +} + +/** + * @title Repay operation decreases user's drawn shares and transfers tokens from user + * @link_property Spoke integrity + */ +rule nothingForZero_repay(uint256 reserveId, uint256 amount, address onBehalfOf) { + env e; + setup(); + address underlying = spoke._reserves[reserveId].underlying; + require e.msg.sender != spoke._reserves[reserveId].hub; + // Same underlying as in hub + require underlying == assetUnderlying[spoke._reserves[reserveId].assetId]; + uint256 drawnSharesBefore = spoke._userPositions[onBehalfOf][reserveId].drawnShares; + uint256 userBalanceBefore = tokenBalanceOf(underlying, e.msg.sender); + mathint premiumDebtBefore = premiumDebtCVL(onBehalfOf, reserveId, e); + + repay(e, reserveId, amount, onBehalfOf); + + mathint premiumDebtAfter = premiumDebtCVL(onBehalfOf, reserveId, e); + // change in debt then must have change in underlying assets + assert ((spoke._userPositions[onBehalfOf][reserveId].drawnShares < drawnSharesBefore || premiumDebtAfter < premiumDebtBefore) => + (tokenBalanceOf(underlying, e.msg.sender) < userBalanceBefore)); + // no change in underlying then no debt covered + assert (tokenBalanceOf(underlying, e.msg.sender) == userBalanceBefore) => (premiumDebtAfter == premiumDebtBefore && spoke._userPositions[onBehalfOf][reserveId].drawnShares == drawnSharesBefore) + ; +} + +/** + * @title Supply integrity - only suppliedShares changes for the user + * @link_property Spoke integrity + */ +rule supply_noChangeToOther(uint256 reserveId, uint256 amount, address onBehalfOf, address user) { + env e; + setup(); + + uint256 drawnSharesBefore = spoke._userPositions[user][reserveId].drawnShares; + uint256 premiumSharesBefore = spoke._userPositions[user][reserveId].premiumShares; + int256 premiumOffsetRayBefore = spoke._userPositions[user][reserveId].premiumOffsetRay; + uint256 suppliedSharesBefore = spoke._userPositions[user][reserveId].suppliedShares; + + supply(e, reserveId, amount, onBehalfOf); + + assert spoke._userPositions[user][reserveId].suppliedShares != suppliedSharesBefore => user == onBehalfOf; + assert spoke._userPositions[user][reserveId].drawnShares == drawnSharesBefore; + assert spoke._userPositions[user][reserveId].premiumShares == premiumSharesBefore; + assert spoke._userPositions[user][reserveId].premiumOffsetRay == premiumOffsetRayBefore; +} + +/** + * @title Withdraw integrity - only suppliedShares changes for the user + * @link_property Spoke integrity + */ +rule withdraw_noChangeToOther(uint256 reserveId, uint256 amount, address onBehalfOf, address user) { + env e; + setup(); + + uint256 drawnSharesBefore = spoke._userPositions[user][reserveId].drawnShares; + uint256 premiumSharesBefore = spoke._userPositions[user][reserveId].premiumShares; + int256 premiumOffsetRayBefore = spoke._userPositions[user][reserveId].premiumOffsetRay; + uint256 suppliedSharesBefore = spoke._userPositions[user][reserveId].suppliedShares; + + withdraw(e, reserveId, amount, onBehalfOf); + + assert spoke._userPositions[user][reserveId].suppliedShares != suppliedSharesBefore => user == onBehalfOf; + assert spoke._userPositions[user][reserveId].drawnShares == drawnSharesBefore; + assert spoke._userPositions[user][reserveId].premiumShares != premiumSharesBefore => user == onBehalfOf; + assert spoke._userPositions[user][reserveId].premiumOffsetRay != premiumOffsetRayBefore => user == onBehalfOf; +} + +/** + * @title Borrow integrity - drawnShares increases, premiumShares may change + * @link_property Spoke integrity + */ +rule borrow_noChangeToOther(uint256 reserveId, uint256 amount, address onBehalfOf, address user) { + env e; + setup(); + + uint256 drawnSharesBefore = spoke._userPositions[user][reserveId].drawnShares; + uint256 premiumSharesBefore = spoke._userPositions[user][reserveId].premiumShares; + int256 premiumOffsetRayBefore = spoke._userPositions[user][reserveId].premiumOffsetRay; + uint256 suppliedSharesBefore = spoke._userPositions[user][reserveId].suppliedShares; + + borrow(e, reserveId, amount, onBehalfOf); + + assert spoke._userPositions[user][reserveId].drawnShares != drawnSharesBefore => user == onBehalfOf; + assert spoke._userPositions[user][reserveId].premiumShares != premiumSharesBefore => user == onBehalfOf; + assert spoke._userPositions[user][reserveId].premiumOffsetRay != premiumOffsetRayBefore => user == onBehalfOf; + assert spoke._userPositions[user][reserveId].suppliedShares == suppliedSharesBefore; +} + +/** + * @title Repay integrity - drawnShares decreases, suppliedShares unchanged + * @link_property Spoke integrity + */ +rule repay_noChangeToOther(uint256 reserveId, uint256 amount, address onBehalfOf, address user) { + env e; + setup(); + + uint256 drawnSharesBefore = spoke._userPositions[user][reserveId].drawnShares; + uint256 premiumSharesBefore = spoke._userPositions[user][reserveId].premiumShares; + int256 premiumOffsetRayBefore = spoke._userPositions[user][reserveId].premiumOffsetRay; + uint256 suppliedSharesBefore = spoke._userPositions[user][reserveId].suppliedShares; + + repay(e, reserveId, amount, onBehalfOf); + + assert spoke._userPositions[user][reserveId].drawnShares != drawnSharesBefore => user == onBehalfOf; + assert spoke._userPositions[user][reserveId].premiumShares != premiumSharesBefore => user == onBehalfOf; + assert spoke._userPositions[user][reserveId].premiumOffsetRay != premiumOffsetRayBefore => user == onBehalfOf; + assert spoke._userPositions[user][reserveId].suppliedShares == suppliedSharesBefore; +} + +/** + * @title Only position manager can change the user's position, or that the caller was verified via the checkCanCall function + * @link_property Spoke integrity + */ +rule onlyPositionManagerCanChange(method f, address user, uint256 reserveId) filtered { f -> !outOfScopeFunctions(f) } { + env e; + calldataarg args; + setup(); + uint256 drawnSharesBefore = spoke._userPositions[user][reserveId].drawnShares; + uint256 premiumSharesBefore = spoke._userPositions[user][reserveId].premiumShares; + int256 premiumOffsetRayBefore = spoke._userPositions[user][reserveId].premiumOffsetRay; + uint256 suppliedSharesBefore = spoke._userPositions[user][reserveId].suppliedShares; + bool isPositionManager = isPositionManager(user, e.msg.sender); + f(e, args); + assert spoke._userPositions[user][reserveId].drawnShares != drawnSharesBefore => isPositionManager; + assert spoke._userPositions[user][reserveId].premiumShares != premiumSharesBefore => (isPositionManager || checkedCanCallGhost == e.msg.sender); + assert spoke._userPositions[user][reserveId].premiumOffsetRay != premiumOffsetRayBefore => (isPositionManager || checkedCanCallGhost == e.msg.sender); + assert spoke._userPositions[user][reserveId].suppliedShares != suppliedSharesBefore => isPositionManager; + +} diff --git a/certora/spec/SpokeUserIntegrity.spec b/certora/spec/SpokeUserIntegrity.spec new file mode 100644 index 000000000..07ea2bb5a --- /dev/null +++ b/certora/spec/SpokeUserIntegrity.spec @@ -0,0 +1,134 @@ +/** + * @title Spoke User Integrity Specification + * @notice Prove that only one user's account is updated and used in a single operation (beside liquidationCall and multicall) + * @dev This allows us to assume that the user is the same throughout the operation in the Spoke.spec rules + * + * To run this spec: + * certoraRun certora/conf/SpokeUserIntegrity.conf + */ + +using SpokeInstance as spoke; + +//////////////////////////////////////////////////////////////////////////// +// METHODS // +//////////////////////////////////////////////////////////////////////////// + +methods { + function Math.mulDiv(uint256 x, uint256 y, uint256 denominator) internal returns (uint256) => NONDET ALL; + function Math.mulDiv(uint256 x, uint256 y, uint256 denominator, Math.Rounding rounding) internal returns (uint256) => NONDET ALL; + + function LibBit.fls(uint256 x) internal returns (uint256) => NONDET ALL; + function LibBit.popCount(uint256 x) internal returns (uint256) => NONDET ALL; + + function WadRayMath.rayMulDown(uint256 a, uint256 b) internal returns (uint256) => NONDET ALL; + + function WadRayMath.rayMulUp(uint256 a, uint256 b) internal returns (uint256) => NONDET ALL; + + function WadRayMath.rayDivDown(uint256 a, uint256 b) internal returns (uint256) => NONDET ALL; + + function WadRayMath.rayDivUp(uint256 a, uint256 b) internal returns (uint256) => NONDET ALL; + + function MathUtils.uncheckedExp(uint256 a, uint256 b) internal returns (uint256) => NONDET ALL; + + function PercentageMath.percentMulDown(uint256 percentage, uint256 value) internal returns (uint256) => NONDET ALL; + + function PercentageMath.percentMulUp(uint256 percentage, uint256 value) internal returns (uint256) => NONDET ALL; + + function WadRayMath.wadDivUp(uint256 a, uint256 b) internal returns (uint256) => NONDET ALL; + + function _.sortByKey(KeyValueList.List memory array) internal => NONDET ALL; + + function _._hashTypedData(bytes32 structHash) internal => NONDET; + + function _.uncheckedAt(KeyValueList.List memory self, uint256 idx) internal => NONDET; + function _.unsafeMemoryAccess(KeyValueList.List memory self, uint256 idx) internal => NONDET ALL; + + function _.extSload(bytes32 slot) external => NONDET DELETE; + function _.extSloads(bytes32[] slots) external => NONDET DELETE; +} + +//////////////////////////////////////////////////////////////////////////// +// GHOSTS // +//////////////////////////////////////////////////////////////////////////// + +persistent ghost address assumeUser; +persistent ghost bool detectedMisuse; + +//////////////////////////////////////////////////////////////////////////// +// DEFINITIONS // +//////////////////////////////////////////////////////////////////////////// + +function checkAndSetUser(address user) { + if (assumeUser != user && assumeUser != 0) { + detectedMisuse = true; + } + assumeUser = user; +} + +//////////////////////////////////////////////////////////////////////////// +// HOOKS // +//////////////////////////////////////////////////////////////////////////// + +hook Sstore _userPositions[KEY address user][KEY uint256 reserveId].drawnShares uint120 newValue (uint120 oldValue) { + checkAndSetUser(user); +} + +hook Sload uint120 value _userPositions[KEY address user][KEY uint256 reserveId].drawnShares { + checkAndSetUser(user); +} + +hook Sstore _userPositions[KEY address user][KEY uint256 reserveId].suppliedShares uint120 newValue (uint120 oldValue) { + checkAndSetUser(user); +} + +hook Sload uint120 value _userPositions[KEY address user][KEY uint256 reserveId].suppliedShares { + checkAndSetUser(user); +} + +hook Sstore _userPositions[KEY address user][KEY uint256 reserveId].premiumShares uint120 newValue (uint120 oldValue) { + checkAndSetUser(user); +} + +hook Sload uint120 value _userPositions[KEY address user][KEY uint256 reserveId].premiumShares { + checkAndSetUser(user); +} + +hook Sstore _userPositions[KEY address user][KEY uint256 reserveId].premiumOffsetRay int200 newValue (int200 oldValue) { + checkAndSetUser(user); +} + +hook Sload int200 value _userPositions[KEY address user][KEY uint256 reserveId].premiumOffsetRay { + checkAndSetUser(user); +} + +hook Sload uint256 value _positionStatus[KEY address user].map[KEY uint256 slot] { + checkAndSetUser(user); +} + +hook Sstore _positionStatus[KEY address user].map[KEY uint256 slot] uint256 value { + checkAndSetUser(user); +} + +//////////////////////////////////////////////////////////////////////////// +// RULES // +//////////////////////////////////////////////////////////////////////////// + +/** + * @title Only one user's account is updated and used in a single operation (beside liquidationCall and multicall) + * @link_property Spoke user integrity + */ +rule userIntegrity(method f) filtered {f -> + f.selector != sig:liquidationCall(uint256, uint256, address, uint256, bool).selector && + f.selector != sig:extSload(bytes32).selector && + f.selector != sig:extSloads(bytes32[]).selector +} { + env e; + calldataarg args; + + assumeUser = 0; + detectedMisuse = false; + + f(e, args); + + assert !detectedMisuse; +} diff --git a/certora/spec/common.spec b/certora/spec/common.spec new file mode 100644 index 000000000..e9be070a6 --- /dev/null +++ b/certora/spec/common.spec @@ -0,0 +1,115 @@ +/** + * @title Common Method Summaries + * @notice Common method summaries used in both Hub and Spoke spec files + * @assumption Asset deciamls is between 6 and 18 + */ + +import "./symbolicRepresentation/Math_CVL.spec"; + +methods { + function Math.mulDiv(uint256 x, uint256 y, uint256 denominator, Math.Rounding rounding) internal returns (uint256) => + mulDivCVL(x, y, denominator, rounding); + + function _.divUp(uint256 a, uint256 b) internal => divUpCVL(a, b) expect uint256; + + function _.mulDivDown(uint256 a, uint256 b, uint256 c) internal => + mulDivDownCVL(a, b, c) expect uint256; + + function _.mulDivUp(uint256 a, uint256 b, uint256 c) internal => + mulDivUpCVL(a, b, c) expect uint256; + + function _.rayMulDown(uint256 a, uint256 b) internal => + mulDivRayDownCVL(a, b) expect uint256; + + function _.rayMulUp(uint256 a, uint256 b) internal => + mulDivRayUpCVL(a, b) expect uint256; + + function _.rayDivDown(uint256 a, uint256 b) internal => + mulDivDownCVL(a, RAY, b) expect uint256; + + function _.fromRayUp(uint256 a) internal => + divRayUpCVL(a) expect uint256; + + function _.toRay(uint256 a) internal => + mulRayCVL(a) expect uint256; + + function _.wadDivUp(uint256 a, uint256 b) internal => + mulDivUpCVL(a, WAD, b) expect uint256; + + function _.wadDivDown(uint256 a, uint256 b) internal => + mulDivDownCVL(a, WAD, b) expect uint256; + + function PercentageMath.percentMulDown(uint256 percentage, uint256 value) internal returns (uint256) => + mulDivDownCVL(value, percentage, PERCENTAGE_FACTOR); + + function PercentageMath.percentMulUp(uint256 percentage, uint256 value) internal returns (uint256) => + mulDivUpCVL(value, percentage, PERCENTAGE_FACTOR); + + function _._checkCanCall(address caller, bytes calldata data) internal => + checkCanCallCVL(caller) expect bool; + + // assume check-effect-interaction. this will not callback to the hub + function _.setInterestRateData(uint256 assetId, bytes data) external => NONDET; + + function _.extSload(bytes32 slot) external => NONDET DELETE; + function _.extSloads(bytes32[] slots) external => NONDET DELETE; +} + + +persistent ghost uint256 RAY { + axiom RAY == 10^27; +} + +persistent ghost uint256 WAD { + axiom WAD == 10^18; +} + +persistent ghost uint256 PERCENTAGE_FACTOR { + axiom PERCENTAGE_FACTOR == 10000; +} + +persistent ghost address checkedCanCallGhost; + +ghost mapping(uint256 /*decimals*/ => uint256 /*value*/) expCVL { + axiom expCVL[0] == 1; + axiom expCVL[1] == 10; + axiom expCVL[2] == 100; + axiom expCVL[3] == 1000; + axiom expCVL[4] == 10000; + axiom expCVL[5] == 100000; + axiom expCVL[6] == 1000000; + axiom expCVL[7] == 10000000; + axiom expCVL[8] == 100000000; + axiom expCVL[9] == 1000000000; + axiom expCVL[10] == 10000000000; + axiom expCVL[11] == 100000000000; + axiom expCVL[12] == 1000000000000; + axiom expCVL[13] == 10000000000000; + axiom expCVL[14] == 100000000000000; + axiom expCVL[15] == 1000000000000000; + axiom expCVL[16] == 10000000000000000; + axiom expCVL[17] == 100000000000000000; + axiom expCVL[18] == 1000000000000000000; +} + + +function toValueCVL(uint256 amount, uint256 decimals, uint256 price) returns (uint256) { + require decimals >= 0 && decimals <= 18, "limiting exp, used as decimals only"; + // 10 ** (18 - decimals) + uint256 toWAd = expCVL[require_uint256(18 - decimals)]; + return require_uint256(amount * toWAd * price); +} + +function checkCanCallCVL(address caller) returns (bool) { + checkedCanCallGhost = caller; + return true; +} + + +function limitedExp(uint256 a, uint256 b) returns (uint256) { + // assumes that b is always the decimals of an asset + // computes 10^b + assert a == 10; + require b >= 6 && b <= 18, "assuming assets' decimals are between 6 and 18"; + return require_uint256(expCVL[b]); +} \ No newline at end of file diff --git a/certora/spec/libs/LibBit.spec b/certora/spec/libs/LibBit.spec new file mode 100644 index 000000000..35ac9c356 --- /dev/null +++ b/certora/spec/libs/LibBit.spec @@ -0,0 +1,105 @@ +/** + * @title LibBit Library Specification + * @description Formal verification of Solady's LibBit utility library. + * This library is used to represent position statuses, such as whether a reserveID it is active, inactive, liquidated, etc. + * Based on this verification we can prove the integrity of the position statuses. + * @dev This spec verifies bitwise operations popCount (count the number of set bits) and fls (find the last set bit). + * + * Verification Scope: + * - popCount: Correctness of bit counting logic. + * - fls: Correctness of finding the most significant set bit. + * - Revert safety: Ensuring bitwise operations never revert. + */ + +//////////////////////////////////////////////////////////////////////////// +// METHODS // +//////////////////////////////////////////////////////////////////////////// + +methods { + function popCount(uint256 x) external returns (uint256) envfree; + function fls(uint256 x) external returns (uint256) envfree; + function isBitTrue(uint256 x, uint16 pos) external returns (bool) envfree; + function changeOneBit(uint256 x, uint16 pos) external returns (uint256) envfree; +} + +//////////////////////////////////////////////////////////////////////////// +// RULES // +//////////////////////////////////////////////////////////////////////////// + +/** + * @title popCount Integrity + * @notice Verifies that popCount(x) correctly counts the number of set bits by flipping bits and checking the delta. + * @link_property LibBit library integrity + */ +rule popCount_integrity(uint256 x, uint16 pos) { + // Base cases + assert popCount(0) == 0, "popCount(0) should be 0"; + assert popCount(max_uint256) == 256, "popCount(max_uint256) should be 256"; + + // Position must be within uint256 range + require pos <= 255; + + uint256 x_count = popCount(x); + // Flip bit at position 'pos' + uint256 x_prime = changeOneBit(x, pos); + uint256 x_prime_count = popCount(x_prime); + + // popCount must change by exactly one when a single bit is flipped + assert x_prime_count - 1 == x_count || x_count == x_prime_count + 1, "popCount delta should be 1"; + + // If the bit was true, count should decrease after flip; otherwise it should increase + assert isBitTrue(x, pos) <=> x_count == x_prime_count + 1, "popCount direction mismatch"; +} + +/** + * @title popCount No Revert + * @notice Ensures that popCount never reverts for any input. + * @link_property LibBit library integrity + */ +rule popCount_noRevert(uint256 x) { + popCount@withrevert(x); + assert !lastReverted, "popCount should never revert"; +} + +/** + * @title fls Integrity + * @notice Verifies that fls(x) correctly identifies the position of the most significant set bit. + * @link_property LibBit library integrity + */ +rule fls_integrity(uint256 x, uint16 pos) { + // Base cases + assert x == 0 <=> fls(x) == 256, "fls(0) should be 256"; + assert x == 1 <=> fls(x) == 0, "fls(1) should be 0"; + + uint256 r = fls(x); + assert r <= 256, "fls result out of bounds"; + + // Any bit above the fls result must be zero + assert (pos > r && pos < 256) => !isBitTrue(x, pos), "Bit above fls should not be set"; + + // The bit at the fls result must be set (if x is not 0) + assert r != 256 => isBitTrue(x, assert_uint16(r)), "Bit at fls position should be set"; + + // Shifting right by fls result should leave exactly 1 + assert (x != 0) => (x >> r == 1), "Shift right by fls mismatch"; +} + +/** + * @title fls No Revert + * @notice Ensures that fls never reverts for any input. + * @link_property LibBit library integrity + */ +rule fls_noRevert(uint256 x) { + fls@withrevert(x); + assert !lastReverted, "fls should never revert"; +} + +/** + * @title isBitTrue No Revert + * @notice Ensures that isBitTrue never reverts for any position. + * @link_property LibBit library integrity + */ +rule isBitTrueNeverRevert(uint256 x, uint16 pos) { + isBitTrue@withrevert(x, pos); + assert !lastReverted, "isBitTrue should never revert"; +} diff --git a/certora/spec/libs/LiquidationLogic.spec b/certora/spec/libs/LiquidationLogic.spec new file mode 100644 index 000000000..8a99feff5 --- /dev/null +++ b/certora/spec/libs/LiquidationLogic.spec @@ -0,0 +1,202 @@ +/** + * @title LiquidationLogic Library Specification + * @notice Formal verification of LiquidationLogic._calculateLiquidationAmounts function. + * @dev This spec verifies properties of liquidation amount calculations, ensuring correct handling of debt and collateral liquidation. + * + * Verification Scope: + * - Balance constraints: Ensuring liquidation amounts do not exceed available balances. + * - Value relationships: Verifying collateral and debt value relationships during liquidation. + * - Debt priority: Ensuring premium debt is liquidated before drawn shares. + */ + +import "../symbolicRepresentation/SymbolicHub.spec"; +import "../common.spec"; +import "../symbolicRepresentation/Math_CVL.spec"; + +using LiquidationLogicHarness as harness; + +//////////////////////////////////////////////////////////////////////////// +// METHODS // +//////////////////////////////////////////////////////////////////////////// + +methods { + function LiquidationLogic.calculateLiquidationBonus(uint256, uint256, uint256, uint256) internal returns (uint256) => LiquidationLogicBonusGhost; + function SpokeUtils.toValue(uint256 amount, uint256 decimals, uint256 price) internal returns (uint256) => toValueCVL(amount, decimals, price); +} + +//////////////////////////////////////////////////////////////////////////// +// GHOSTS // +//////////////////////////////////////////////////////////////////////////// + +ghost uint256 LiquidationLogicBonusGhost { + axiom LiquidationLogicBonusGhost >= PERCENTAGE_FACTOR; +} + +ghost uint256 computedDebtRayToLiquidateGhost; +ghost bool isDebtRayToLiquidateRecomputedGhost; + +function store(uint256 val) returns (uint256) { + computedDebtRayToLiquidateGhost = val; + isDebtRayToLiquidateRecomputedGhost = true; + return val; +} + +ghost uint256 collateralToLiquidateRecomputedGhost; +ghost bool isCollateralToLiquidateRecomputedRecomputedGhost; + +function storeCollateralToLiquidateRecomputed(uint256 val) returns (uint256) { + collateralToLiquidateRecomputedGhost = val; + isCollateralToLiquidateRecomputedRecomputedGhost = true; + return val; +} + +//////////////////////////////////////////////////////////////////////////// +// RULES // +//////////////////////////////////////////////////////////////////////////// + +/** + * @title Sanity check - function can succeed + */ +rule sanityCheck() { + env e; + LiquidationLogic.CalculateLiquidationAmountsParams params; + + LiquidationLogic.LiquidationAmounts result = harness.calculateLiquidationAmounts(e, params); + + satisfy true; +} + +/** + * @title Debt to liquidate cannot exceed user's current debt shares + * @link_property LiquidationLogic library integrity + */ +rule debtToLiquidateNotExceedBalance() { + env e; + LiquidationLogic.CalculateLiquidationAmountsParams params; + + LiquidationLogic.LiquidationAmounts result = harness.calculateLiquidationAmounts(e, params); + + assert result.drawnSharesToLiquidate <= params.drawnShares; +} + +/** + * @title Debt to liquidate (in assets) cannot exceed debt to cover + * @link_property LiquidationLogic library integrity + */ +rule debtToLiquidateNotExceedDebtToCover() { + env e; + LiquidationLogic.CalculateLiquidationAmountsParams params; + + LiquidationLogic.LiquidationAmounts result = harness.calculateLiquidationAmounts(e, params); + + mathint debtAssetsToLiquidate = mulDivUpCVL(result.drawnSharesToLiquidate, params.drawnIndex, RAY); + assert debtAssetsToLiquidate <= params.debtToCover; +} + +/** + * @title Collateral to liquidator cannot exceed collateral to liquidate + * @link_property LiquidationLogic library integrity + */ +rule collateralToLiquidatorNotExceedTotal() { + env e; + LiquidationLogic.CalculateLiquidationAmountsParams params; + + LiquidationLogic.LiquidationAmounts result = harness.calculateLiquidationAmounts(e, params); + + assert result.collateralSharesToLiquidator <= result.collateralSharesToLiquidate; + assert result.collateralSharesToLiquidate <= params.suppliedShares; +} + + +/** + * @title Collateral to liquidate value less than debt to liquidate value + * @notice Assumes liquidationBonus is none (PERCENTAGE_FACTOR) + * @link_property LiquidationLogic library integrity + */ +rule collateralToLiquidateValueLessThanDebtToLiquidate() { + env e; + LiquidationLogic.CalculateLiquidationAmountsParams params; + require params.debtAssetDecimals == 18; + require params.collateralAssetDecimals == 18; + require params.debtAssetPrice > 0; + require params.collateralAssetPrice > 0; + require params.drawnIndex >= RAY; + + // no bonus + require LiquidationLogicBonusGhost == PERCENTAGE_FACTOR; + + // proved in Spoke.spec rule drawnSharesZero + // liquidation also obeys this rule as if it returns all shares it returns all premium debt + // rule: drawnSharesZeroed_premiumDebtRayZeroed + require params.drawnShares == 0 => params.premiumDebtRay == 0; + + require params.totalDebtValueRay >= ((params.drawnShares * params.drawnIndex) + params.premiumDebtRay) * params.debtAssetPrice; + + LiquidationLogic.LiquidationAmounts result = harness.calculateLiquidationAmounts(e, params); + + mathint debtValueLiquidatedRay = (result.drawnSharesToLiquidate * params.drawnIndex + result.premiumDebtRayToLiquidate) * params.debtAssetPrice; + + mathint collateralValueLiquidatedRay = result.collateralSharesToLiquidate * shareToAssetsRatio[params.collateralReserveAssetId][e.block.timestamp] * params.collateralAssetPrice; + + assert collateralValueLiquidatedRay <= debtValueLiquidatedRay; +} + + + +/** + * @title Collateral to liquidate value less than debt to liquidate value (general case) + * @notice Assumes liquidationBonus is none (PERCENTAGE_FACTOR) + * @dev Handles different decimal configurations (12 + 16 decimals) + * @link_property LiquidationLogic library integrity + */ +rule collateralToLiquidateValueLessThanDebtToLiquidate_general() { + env e; + LiquidationLogic.CalculateLiquidationAmountsParams params; + require params.debtAssetDecimals == 12; + require params.collateralAssetDecimals == 16; + require params.debtAssetPrice > 0; + require params.collateralAssetPrice > 0; + require params.drawnIndex >= RAY; + + // no bonus + require LiquidationLogicBonusGhost == PERCENTAGE_FACTOR; + + // proved in Spoke.spec rule drawnSharesZero + // liquidation also obeys this rule as if it returns all shares it returns all premium debt + // rule: drawnSharesZeroed_premiumDebtRayZeroed + require params.drawnShares == 0 => params.premiumDebtRay == 0; + + require params.totalDebtValueRay >= ((params.drawnShares * params.drawnIndex) + params.premiumDebtRay) * params.debtAssetPrice; + + LiquidationLogic.LiquidationAmounts result = harness.calculateLiquidationAmounts(e, params); + + mathint debtValueLiquidatedRay = (((result.drawnSharesToLiquidate * params.drawnIndex) + result.premiumDebtRayToLiquidate) * params.debtAssetPrice) / limitedExp(10, params.debtAssetDecimals); + + mathint collateralValueLiquidatedRay = (result.collateralSharesToLiquidate * shareToAssetsRatio[params.collateralReserveAssetId][e.block.timestamp] * params.collateralAssetPrice) / limitedExp(10, params.collateralAssetDecimals); + + assert collateralValueLiquidatedRay <= debtValueLiquidatedRay; +} + +/** + * @title Drawn shares zeroed implies premium debt ray zeroed + * @notice Proved in Spoke rule: drawnSharesZero + * @link_property LiquidationLogic library integrity + */ +rule drawnSharesZeroed_premiumDebtRayZeroed() { + env e; + LiquidationLogic.CalculateLiquidationAmountsParams params; + require params.debtAssetPrice > 0; + require params.collateralAssetPrice > 0; + require params.drawnIndex >= RAY; + // proved in Spoke rule: drawnSharesZero + require params.drawnShares == 0 => params.premiumDebtRay == 0; + + require params.totalDebtValueRay >= (mulDivUpCVL(params.drawnShares, params.drawnIndex, RAY) + divRayUpCVL(params.premiumDebtRay)) * params.debtAssetPrice; + + LiquidationLogic.LiquidationAmounts result = harness.calculateLiquidationAmounts(e, params); + + assert result.drawnSharesToLiquidate == params.drawnShares => result.premiumDebtRayToLiquidate == params.premiumDebtRay; + // first repay all premium debt + assert result.drawnSharesToLiquidate != 0 => result.premiumDebtRayToLiquidate == params.premiumDebtRay; +} + diff --git a/certora/spec/libs/LiquidationLogic_Bonus.spec b/certora/spec/libs/LiquidationLogic_Bonus.spec new file mode 100644 index 000000000..866cf2156 --- /dev/null +++ b/certora/spec/libs/LiquidationLogic_Bonus.spec @@ -0,0 +1,189 @@ +/** + * @title LiquidationLogic_Bonus Spec + * @notice Specification for LiquidationLogic.calculateLiquidationBonus + * + * The function calculates liquidation bonus based on health factor: + * - If healthFactor <= healthFactorForMaxBonus: returns maxLiquidationBonus + * - Otherwise: linear interpolation between minLiquidationBonus and maxLiquidationBonus + */ + +using LiquidationLogicHarness as harness; + +methods { + function calculateLiquidationBonus( + uint256 healthFactorForMaxBonus, + uint256 liquidationBonusFactor, + uint256 healthFactor, + uint256 maxLiquidationBonus + ) external returns (uint256) envfree; +} + +// Constants +definition PERCENTAGE_FACTOR() returns uint256 = 10000; // 100% in BPS +definition HEALTH_FACTOR_LIQUIDATION_THRESHOLD() returns uint256 = 10^18; + +/// @title Sanity check - function can succeed +rule sanityCheck() { + uint256 healthFactorForMaxBonus; + uint256 liquidationBonusFactor; + uint256 healthFactor; + uint256 maxLiquidationBonus; + + uint256 result = harness.calculateLiquidationBonus( + healthFactorForMaxBonus, + liquidationBonusFactor, + healthFactor, + maxLiquidationBonus + ); + + satisfy true; +} + +/** + * @title When healthFactor <= healthFactorForMaxBonus, returns maxLiquidationBonus + * @link_property LiquidationLogic library integrity + */ +rule maxBonusWhenLowHealthFactor() { + uint256 healthFactorForMaxBonus; + uint256 liquidationBonusFactor; + uint256 healthFactor; + uint256 maxLiquidationBonus; + + require healthFactor <= healthFactorForMaxBonus; + + uint256 result = harness.calculateLiquidationBonus( + healthFactorForMaxBonus, + liquidationBonusFactor, + healthFactor, + maxLiquidationBonus + ); + + assert result == maxLiquidationBonus; +} + +/** + * @title Result is always >= PERCENTAGE_FACTOR (no negative bonus) + * @link_property LiquidationLogic library integrity + */ +rule bonusIsAtLeastNoBonus() { + uint256 healthFactorForMaxBonus; + uint256 liquidationBonusFactor; + uint256 healthFactor; + uint256 maxLiquidationBonus; + + // Preconditions for valid inputs + require maxLiquidationBonus >= PERCENTAGE_FACTOR(); + require healthFactorForMaxBonus < HEALTH_FACTOR_LIQUIDATION_THRESHOLD(); + require healthFactor <= HEALTH_FACTOR_LIQUIDATION_THRESHOLD(); + + uint256 result = harness.calculateLiquidationBonus( + healthFactorForMaxBonus, + liquidationBonusFactor, + healthFactor, + maxLiquidationBonus + ); + + assert result >= PERCENTAGE_FACTOR(); +} + +/** + * @title Result is always <= maxLiquidationBonus + * @link_property LiquidationLogic library integrity + */ +rule bonusDoesNotExceedMax() { + uint256 healthFactorForMaxBonus; + uint256 liquidationBonusFactor; + uint256 healthFactor; + uint256 maxLiquidationBonus; + + uint256 result = harness.calculateLiquidationBonus( + healthFactorForMaxBonus, + liquidationBonusFactor, + healthFactor, + maxLiquidationBonus + ); + + assert result <= maxLiquidationBonus; +} + +/** + * @title Monotonicity: higher healthFactor results in lower or equal bonus + * @link_property LiquidationLogic library integrity + */ +rule monotonicityOfBonus() { + uint256 healthFactorForMaxBonus; + uint256 liquidationBonusFactor; + uint256 healthFactor1; + uint256 healthFactor2; + uint256 maxLiquidationBonus; + + require healthFactor1 < healthFactor2; + require healthFactor2 <= HEALTH_FACTOR_LIQUIDATION_THRESHOLD(); + require healthFactorForMaxBonus < HEALTH_FACTOR_LIQUIDATION_THRESHOLD(); + + uint256 result1 = harness.calculateLiquidationBonus( + healthFactorForMaxBonus, + liquidationBonusFactor, + healthFactor1, + maxLiquidationBonus + ); + + uint256 result2 = harness.calculateLiquidationBonus( + healthFactorForMaxBonus, + liquidationBonusFactor, + healthFactor2, + maxLiquidationBonus + ); + + assert result1 >= result2; +} + +/** + * @title At threshold, bonus equals minLiquidationBonus + * @link_property LiquidationLogic library integrity + */ +rule bonusAtThreshold() { + uint256 healthFactorForMaxBonus; + uint256 liquidationBonusFactor; + uint256 maxLiquidationBonus; + + require healthFactorForMaxBonus < HEALTH_FACTOR_LIQUIDATION_THRESHOLD(); + require maxLiquidationBonus >= PERCENTAGE_FACTOR(); + + uint256 result = harness.calculateLiquidationBonus( + healthFactorForMaxBonus, + liquidationBonusFactor, + HEALTH_FACTOR_LIQUIDATION_THRESHOLD(), + maxLiquidationBonus + ); + + // At threshold, should return minLiquidationBonus which is computed as: + // (maxLiquidationBonus - PERCENTAGE_FACTOR).percentMulDown(liquidationBonusFactor) + PERCENTAGE_FACTOR + mathint expectedMin = ((maxLiquidationBonus - PERCENTAGE_FACTOR()) * liquidationBonusFactor / PERCENTAGE_FACTOR()) + PERCENTAGE_FACTOR(); + + assert result == assert_uint256(expectedMin); +} + +/** + * @title Zero bonus factor means min bonus equals PERCENTAGE_FACTOR + * @link_property LiquidationLogic library integrity + */ +rule zeroBonusFactorMeansNoMinBonus() { + uint256 healthFactorForMaxBonus; + uint256 healthFactor; + uint256 maxLiquidationBonus; + + require healthFactor > healthFactorForMaxBonus; + require healthFactor == HEALTH_FACTOR_LIQUIDATION_THRESHOLD(); + require maxLiquidationBonus >= PERCENTAGE_FACTOR(); + + uint256 result = harness.calculateLiquidationBonus( + healthFactorForMaxBonus, + 0, // zero bonus factor + healthFactor, + maxLiquidationBonus + ); + + assert result == PERCENTAGE_FACTOR(); +} + diff --git a/certora/spec/libs/LiquidationLogic_debtToLiquidate.spec b/certora/spec/libs/LiquidationLogic_debtToLiquidate.spec new file mode 100644 index 000000000..b0f61dbab --- /dev/null +++ b/certora/spec/libs/LiquidationLogic_debtToLiquidate.spec @@ -0,0 +1,70 @@ +/** + * @title LiquidationLogic_debtToLiquidate Library Specification + * @notice Formal verification of LiquidationLogic.calculateDebtToLiquidate function. + * @dev This spec verifies properties of debt liquidation calculations, ensuring correct handling of drawn shares and premium debt. + * + * Verification Scope: + * - Balance constraints: Ensuring debt to liquidate does not exceed available balances. + * - Priority ordering: Verifying that premium debt is liquidated before drawn shares. + */ + +import "../common.spec"; +import "../symbolicRepresentation/Math_CVL.spec"; + +using LiquidationLogicHarness as harness; + +//////////////////////////////////////////////////////////////////////////// +// METHODS // +//////////////////////////////////////////////////////////////////////////// + +methods { + function calculateDebtToLiquidate( + LiquidationLogic.CalculateDebtToLiquidateParams params + ) external returns (uint256, uint256) envfree; +} + +//////////////////////////////////////////////////////////////////////////// +// RULES // +//////////////////////////////////////////////////////////////////////////// + +/** + * @title Sanity check for calculateDebtToLiquidate + * @link_property LiquidationLogic library integrity + */ +rule sanityCheck() { + LiquidationLogic.CalculateDebtToLiquidateParams params; + uint256 drawnShares; + uint256 premiumDebt; + (drawnShares, premiumDebt) = harness.calculateDebtToLiquidate(params); + satisfy true; +} + +/** + * @title Verify that debt to liquidate does not exceed balance + * @link_property LiquidationLogic library integrity + */ +rule debtToLiquidateNotExceedBalance() { + LiquidationLogic.CalculateDebtToLiquidateParams params; + + uint256 drawnShares; + uint256 premiumDebt; + (drawnShares, premiumDebt) = harness.calculateDebtToLiquidate(params); + + assert drawnShares <= params.drawnShares; + assert premiumDebt <= params.premiumDebtRay; +} + +/** + * @title Verify that premium debt is liquidated first + * @notice If drawnSharesToLiquidate > 0, then premiumDebtRayToLiquidate must equal params.premiumDebtRay + * @link_property LiquidationLogic library integrity + */ +rule premiumDebtLiquidatedFirst() { + LiquidationLogic.CalculateDebtToLiquidateParams params; + + uint256 drawnShares; + uint256 premiumDebt; + (drawnShares, premiumDebt) = harness.calculateDebtToLiquidate(params); + + assert drawnShares > 0 => premiumDebt == params.premiumDebtRay; +} diff --git a/certora/spec/libs/Math.spec b/certora/spec/libs/Math.spec new file mode 100644 index 000000000..908464f6c --- /dev/null +++ b/certora/spec/libs/Math.spec @@ -0,0 +1,290 @@ +/** + * @title Math Library Specification + * @notice Formal verification of mathematical utility libraries, ensuring CVL summaries match Solidity implementations. + * @dev This spec verifies MathUtils, WadRayMath, and PercentageMath by comparing them against their symbolic CVL representations. + * + * Verification Scope: + * - Functional equivalence between Solidity and CVL implementations. + * - Revert condition parity (ensuring both fail under the same circumstances). + * - Mathematical properties like associativity for percentage calculations. + */ + +import "../symbolicRepresentation/Math_CVL.spec"; + +//////////////////////////////////////////////////////////////////////////// +// METHODS // +//////////////////////////////////////////////////////////////////////////// + +methods { + // envfree functions + function RAY() external returns (uint256) envfree; + function WAD() external returns (uint256) envfree; + function PERCENTAGE_FACTOR() external returns (uint256) envfree; + function rayMulDown(uint256 a, uint256 b) external returns (uint256) envfree; + function rayMulUp(uint256 a, uint256 b) external returns (uint256) envfree; + function rayDivDown(uint256 a, uint256 b) external returns (uint256) envfree; + function rayDivUp(uint256 a, uint256 b) external returns (uint256) envfree; + function wadDivDown(uint256 a, uint256 b) external returns (uint256) envfree; + function wadDivUp(uint256 a, uint256 b) external returns (uint256) envfree; + function percentMulDown(uint256 percentage, uint256 value) external returns (uint256) envfree; + function percentMulUp(uint256 percentage, uint256 value) external returns (uint256) envfree; + function mulDivDown(uint256 x, uint256 y, uint256 denominator) external returns (uint256) envfree; + function mulDivUp(uint256 x, uint256 y, uint256 denominator) external returns (uint256) envfree; + function divUp(uint256 a, uint256 b) external returns (uint256) envfree; + function fromRayUp(uint256 a) external returns (uint256) envfree; + function toRay(uint256 a) external returns (uint256) envfree; + function mulDiv(uint256 x, uint256 y, uint256 denominator, Math.Rounding rounding) external returns (uint256) envfree; +} + +//////////////////////////////////////////////////////////////////////////// +// GHOSTS // +//////////////////////////////////////////////////////////////////////////// + +persistent ghost uint256 PERCENTAGE_FACTOR { + axiom PERCENTAGE_FACTOR == 10000; +} + +//////////////////////////////////////////////////////////////////////////// +// RULES // +//////////////////////////////////////////////////////////////////////////// + +/** + * @title MathUtils.mulDivDown Equivalence + * @notice Verifies that MathUtils.mulDivDown matches the symbolic mulDivDownCVL implementation. + * @link_property Math library integrity + */ +rule MathUtils_mulDivDown(uint256 x, uint256 y, uint256 denominator) { + uint256 cvlResult = mulDivDownCVL@withrevert(x, y, denominator); + bool cvlReverted = lastReverted; + uint256 solResult = mulDivDown@withrevert(x, y, denominator); + bool solReverted = lastReverted; + assert cvlReverted == solReverted, "Revert condition mismatch"; + assert !cvlReverted => cvlResult == solResult, "Result value mismatch"; +} + +/** + * @title MathUtils.mulDivUp Equivalence + * @notice Verifies that MathUtils.mulDivUp matches the symbolic mulDivUpCVL implementation. + * @link_property Math library integrity + */ +rule MathUtils_mulDivUp(uint256 x, uint256 y, uint256 denominator) { + uint256 cvlResult = mulDivUpCVL@withrevert(x, y, denominator); + bool cvlReverted = lastReverted; + uint256 solResult = mulDivUp@withrevert(x, y, denominator); + bool solReverted = lastReverted; + assert cvlReverted == solReverted, "Revert condition mismatch"; + assert !cvlReverted => cvlResult == solResult, "Result value mismatch"; +} + +/** + * @title MathUtils.divUp Equivalence + * @notice Verifies that MathUtils.divUp matches the symbolic divUpCVL implementation. + * @link_property Math library integrity + */ +rule MathUtils_divUp(uint256 a, uint256 b) { + uint256 cvlResult = divUpCVL@withrevert(a, b); + bool cvlReverted = lastReverted; + uint256 solResult = divUp@withrevert(a, b); + bool solReverted = lastReverted; + assert cvlReverted == solReverted, "Revert condition mismatch"; + assert !cvlReverted => cvlResult == solResult, "Result value mismatch"; +} + +/** + * @title WadRayMath.rayMulDown Equivalence + * @notice Verifies that WadRayMath.rayMulDown matches the symbolic mulDivDownCVL(a, b, RAY) implementation. + * @link_property Math library integrity + */ +rule WadRayMathExtended_rayMulDown(uint256 a, uint256 b) { + uint256 cvlResult = mulDivDownCVL@withrevert(a, b, RAY()); + bool cvlReverted = lastReverted; + uint256 solResult = rayMulDown@withrevert(a, b); + bool solReverted = lastReverted; + assert cvlReverted == solReverted, "Revert condition mismatch"; + assert !cvlReverted => cvlResult == solResult, "Result value mismatch"; +} + +/** + * @title WadRayMath.rayMulUp Equivalence + * @notice Verifies that WadRayMath.rayMulUp matches the symbolic mulDivUpCVL(a, b, RAY) implementation. + * @link_property Math library integrity + */ +rule WadRayMathExtended_rayMulUp(uint256 a, uint256 b) { + uint256 cvlResult = mulDivUpCVL@withrevert(a, b, RAY()); + bool cvlReverted = lastReverted; + uint256 solResult = rayMulUp@withrevert(a, b); + bool solReverted = lastReverted; + assert cvlReverted == solReverted, "Revert condition mismatch"; + assert !cvlReverted => cvlResult == solResult, "Result value mismatch"; +} + +/** + * @title WadRayMath.rayDivDown Equivalence + * @notice Verifies that WadRayMath.rayDivDown matches the symbolic mulDivDownCVL(a, RAY, b) implementation. + * @link_property Math library integrity + */ +rule WadRayMathExtended_rayDivDown(uint256 a, uint256 b) { + uint256 cvlResult = mulDivDownCVL@withrevert(a, RAY(), b); + bool cvlReverted = lastReverted; + uint256 solResult = rayDivDown@withrevert(a, b); + bool solReverted = lastReverted; + assert cvlReverted == solReverted, "Revert condition mismatch"; + assert !cvlReverted => cvlResult == solResult, "Result value mismatch"; +} + +/** + * @title WadRayMath.rayDivUp Equivalence + * @notice Verifies that WadRayMath.rayDivUp matches the symbolic mulDivUpCVL(a, RAY, b) implementation. + * @link_property Math library integrity + */ +rule WadRayMathExtended_rayDivUp(uint256 a, uint256 b) { + uint256 cvlResult = mulDivUpCVL@withrevert(a, RAY(), b); + bool cvlReverted = lastReverted; + uint256 solResult = rayDivUp@withrevert(a, b); + bool solReverted = lastReverted; + assert cvlReverted == solReverted, "Revert condition mismatch"; + assert !cvlReverted => cvlResult == solResult, "Result value mismatch"; +} + +/** + * @title WadRayMath.wadDivDown Equivalence + * @notice Verifies that WadRayMath.wadDivDown matches the symbolic mulDivDownCVL(a, WAD, b) implementation. + * @link_property Math library integrity + */ +rule WadRayMathExtended_wadDivDown(uint256 a, uint256 b) { + uint256 cvlResult = mulDivDownCVL@withrevert(a, WAD(), b); + bool cvlReverted = lastReverted; + uint256 solResult = wadDivDown@withrevert(a, b); + bool solReverted = lastReverted; + assert cvlReverted == solReverted, "Revert condition mismatch"; + assert !cvlReverted => cvlResult == solResult, "Result value mismatch"; +} + +/** + * @title WadRayMath.wadDivUp Equivalence + * @notice Verifies that WadRayMath.wadDivUp matches the symbolic mulDivUpCVL(a, WAD, b) implementation. + * @link_property Math library integrity + */ +rule WadRayMathExtended_wadDivUp(uint256 a, uint256 b) { + uint256 cvlResult = mulDivUpCVL@withrevert(a, WAD(), b); + bool cvlReverted = lastReverted; + uint256 solResult = wadDivUp@withrevert(a, b); + bool solReverted = lastReverted; + assert cvlReverted == solReverted, "Revert condition mismatch"; + assert !cvlReverted => cvlResult == solResult, "Result value mismatch"; +} + +/** + * @title WadRayMath.fromRayUp Equivalence + * @notice Verifies that WadRayMath.fromRayUp matches the symbolic divRayUpCVL implementation. + * @link_property Math library integrity + */ +rule WadRayMathExtended_fromRayUp(uint256 a) { + uint256 cvlResult = divRayUpCVL@withrevert(a); + bool cvlReverted = lastReverted; + uint256 solResult = fromRayUp@withrevert(a); + bool solReverted = lastReverted; + assert cvlReverted == solReverted, "Revert condition mismatch"; + assert !cvlReverted => cvlResult == solResult, "Result value mismatch"; +} + +/** + * @title WadRayMath.toRay Equivalence + * @notice Verifies that WadRayMath.toRay matches the symbolic mulRayCVL implementation. + * @link_property Math library integrity + */ +rule WadRayMathExtended_toRay(uint256 a) { + uint256 cvlResult = mulRayCVL@withrevert(a); + bool cvlReverted = lastReverted; + uint256 solResult = toRay@withrevert(a); + bool solReverted = lastReverted; + assert cvlReverted == solReverted, "Revert condition mismatch"; + assert !cvlReverted => cvlResult == solResult, "Result value mismatch"; +} + +/** + * @title PercentageMath.percentMulDown Equivalence + * @notice Verifies that PercentageMath.percentMulDown matches the symbolic mulDivDownCVL implementation. + * @link_property Math library integrity + */ +rule percentMulDown_integrity(uint256 percentage, uint256 value) { + uint256 solResult = percentMulDown@withrevert(value, percentage); + bool solReverted = lastReverted; + uint256 cvlResult = mulDivDownCVL@withrevert(value, percentage, PERCENTAGE_FACTOR); + bool cvlReverted = lastReverted; + assert cvlReverted == solReverted, "Revert condition mismatch"; + assert !cvlReverted => cvlResult == solResult, "Result value mismatch"; +} + +/** + * @title PercentageMath.percentMulDown Associativity + * @notice Proves that the order of arguments (value vs percentage) does not change the result for percentMulDown. + * @link_property Math library integrity +*/ +rule percentMulDown_associativity(uint256 percentage, uint256 value) { + uint256 result1 = percentMulDown@withrevert(percentage, value); + bool result1Reverted = lastReverted; + uint256 result2 = percentMulDown@withrevert(value, percentage); + bool result2Reverted = lastReverted; + assert result1Reverted == result2Reverted, "Revert condition mismatch"; + assert !result1Reverted => result1 == result2, "Result value mismatch"; + satisfy value == 0 && !result1Reverted; + satisfy percentage == 0 && !result1Reverted; +} + +/** + * @title PercentageMath.percentMulUp Equivalence + * @notice Verifies that PercentageMath.percentMulUp matches the symbolic mulDivUpCVL implementation. + * @link_property Math library integrity + */ +rule percentMulUp_integrity(uint256 percentage, uint256 value) { + uint256 solResult = percentMulUp@withrevert(value, percentage); + bool solReverted = lastReverted; + uint256 cvlResult = mulDivUpCVL@withrevert(value, percentage, PERCENTAGE_FACTOR); + bool cvlReverted = lastReverted; + assert cvlReverted == solReverted, "Revert condition mismatch"; + assert !cvlReverted => cvlResult == solResult, "Result value mismatch"; +} + +/** + * @title PercentageMath.percentMulUp Associativity + * @notice Proves that the order of arguments (value vs percentage) does not change the result for percentMulUp. + * @link_property Math library integrity + */ +rule percentMulUp_associativity(uint256 percentage, uint256 value) { + uint256 result1 = percentMulUp@withrevert(percentage, value); + bool result1Reverted = lastReverted; + uint256 result2 = percentMulUp@withrevert(value, percentage); + bool result2Reverted = lastReverted; + assert result1Reverted == result2Reverted, "Revert condition mismatch"; + assert !result1Reverted => result1 == result2, "Result value mismatch"; + satisfy value == 0 && !result1Reverted; + satisfy percentage == 0 && !result1Reverted; +} + +/** + * @title RAY Definition + * @notice Constant check for RAY (10^27). + * @link_property Math library integrity + */ +rule RAY_definition() { + assert RAY() == 10^27; +} + +/** + * @title WAD Definition + * @notice Constant check for WAD (10^18). + * @link_property Math library integrity + */ +rule WAD_definition() { + assert WAD() == 10^18; +} + +/** + * @title PERCENTAGE_FACTOR Definition + * @notice Constant check for PERCENTAGE_FACTOR (10000). + * @link_property Math library integrity + */ +rule PERCENTAGE_FACTOR_definition() { + assert PERCENTAGE_FACTOR() == PERCENTAGE_FACTOR; +} diff --git a/certora/spec/libs/PositionStatus.spec b/certora/spec/libs/PositionStatus.spec new file mode 100644 index 000000000..38c674eb7 --- /dev/null +++ b/certora/spec/libs/PositionStatus.spec @@ -0,0 +1,212 @@ +/** + * @title PositionStatus Library Specification + * @notice Formal verification of the PositionStatusMap library. + * @dev This spec verifies the management of user position flags (borrowing and collateral) using bitwise operations. + * It relies on summaries of LibBit.sol, which are verified in LibBit.spec. + * + * Verification Scope: + * - Flag integrity: Ensuring setBorrowing and setUsingAsCollateral only affect the intended reserve. + * - Search correctness: Verifying that next, nextBorrowing, and nextCollateral correctly find the next active reserve. + * - Counter integrity: Ensuring collateralCount accurately reflects the number of active collateral positions. + * - Revert safety: Ensuring all library functions are non-reverting. + */ + +//////////////////////////////////////////////////////////////////////////// +// METHODS // +//////////////////////////////////////////////////////////////////////////// + +methods { + function setBorrowing(uint256 reserveId, bool borrowing) external envfree; + function setUsingAsCollateral(uint256 reserveId, bool usingAsCollateral) external envfree; + function isUsingAsCollateralOrBorrowing(uint256 reserveId) external returns (bool) envfree; + function isBorrowing(uint256 reserveId) external returns (bool) envfree; + function isUsingAsCollateral(uint256 reserveId) external returns (bool) envfree; + function collateralCount(uint256 reserveCount) external returns (uint256) envfree; + function next(uint256 startReserveId) external returns (uint256, bool, bool) envfree; + function nextBorrowing(uint256 startReserveId) external returns (uint256) envfree; + function nextCollateral(uint256 startReserveId) external returns (uint256) envfree; + function getBucketWord(uint256 reserveId) external returns (uint256) envfree; + + function _.fls(uint256 word) internal => flsResult(word) expect uint256; +} + +//////////////////////////////////////////////////////////////////////////// +// GHOSTS // +//////////////////////////////////////////////////////////////////////////// + +/** + * @dev flsResult(word) is the position of the last (most significant) set bit in word. + * Axiomatized based on LibBit.spec proofs. + */ +ghost flsResult(uint256) returns uint256 { + axiom flsResult(0) == 256; + axiom forall uint256 word. word != 0 => (word >> flsResult(word) == 1); +} + +//////////////////////////////////////////////////////////////////////////// +// RULES // +//////////////////////////////////////////////////////////////////////////// + +/** + * @title setBorrowing Integrity + * @notice Verifies that setBorrowing correctly updates the target reserve's flag and preserves all other reserve flags. + * @link_property PositionStatusMap integrity + */ +rule setBorrowing(uint256 reserveId, bool borrowing) { + uint256 otherId; + require reserveId != otherId; + + bool borrowingFlagOther = isBorrowing(otherId); + bool collateralFlagOther = isUsingAsCollateral(otherId); + + setBorrowing(reserveId, borrowing); + + assert isBorrowing(otherId) == borrowingFlagOther, "Other reserve borrowing flag changed"; + assert isUsingAsCollateral(otherId) == collateralFlagOther, "Other reserve collateral flag changed"; + assert isBorrowing(reserveId) == borrowing, "Target reserve borrowing flag mismatch"; +} + +/** + * @title setUsingAsCollateral Integrity + * @notice Verifies that setUsingAsCollateral correctly updates the target reserve's flag and preserves all other reserve flags. + * @link_property PositionStatusMap integrity + */ +rule setUsingAsCollateral(uint256 reserveId, bool usingAsCollateral) { + uint256 otherId; + require reserveId != otherId; + + bool borrowingFlagOther = isBorrowing(otherId); + bool collateralFlagOther = isUsingAsCollateral(otherId); + + setUsingAsCollateral(reserveId, usingAsCollateral); + + assert isBorrowing(otherId) == borrowingFlagOther, "Other reserve borrowing flag changed"; + assert isUsingAsCollateral(otherId) == collateralFlagOther, "Other reserve collateral flag changed"; + assert isUsingAsCollateral(reserveId) == usingAsCollateral, "Target reserve collateral flag mismatch"; +} + +/** + * @title isUsingAsCollateralOrBorrowing Logic + * @notice Verifies that the combined check correctly reflects the individual borrowing and collateral flags. + * @link_property PositionStatusMap integrity + */ +rule isUsingAsCollateralOrBorrowing(uint256 reserveId) { + assert isUsingAsCollateralOrBorrowing(reserveId) <=> (isUsingAsCollateral(reserveId) || isBorrowing(reserveId)), "Combined flag mismatch"; +} + +/** + * @title Collateral Count Integrity + * @notice Verifies that the collateralCount is correctly incremented or decremented when a reserve's collateral status changes. + * @link_property PositionStatusMap integrity + */ +rule collateralCount(uint256 reserveCount, bool usingAsCollateral, uint256 reserveId) { + require reserveId < reserveCount; + + uint256 countBefore = collateralCount(reserveCount); + bool flagBefore = isUsingAsCollateral(reserveId); + + setUsingAsCollateral(reserveId, usingAsCollateral); + + uint256 countAfter = collateralCount(reserveCount); + + if (usingAsCollateral == flagBefore) { + assert countBefore == countAfter, "Count changed without flag change"; + } else { + assert countAfter == countBefore + (usingAsCollateral ? 1 : -1), "Count delta mismatch"; + } +} + +/** + * @title Next Active Reserve Search + * @notice Verifies that the 'next' function correctly finds the nearest active reserve (borrowing or collateral) below the start index. + * @link_property PositionStatusMap integrity + */ +rule next(uint256 startReserveId) { + uint256 NOT_FOUND = max_uint256; + uint256 reserveId; + bool borrowing; + bool collateral; + + // Assume max_uint256 is not a valid reserve ID + require !isBorrowing(max_uint256) && !isUsingAsCollateral(max_uint256); + + reserveId, borrowing, collateral = next(startReserveId); + + uint256 nextBorrowingId = nextBorrowing(startReserveId); + uint256 nextCollateralId = nextCollateral(startReserveId); + + if (reserveId == NOT_FOUND) { + assert nextBorrowingId == NOT_FOUND && nextCollateralId == NOT_FOUND, "Next found when individual searches failed"; + assert !borrowing && !collateral, "Flags set when no reserve found"; + } else { + assert nextBorrowingId == reserveId || nextCollateralId == reserveId, "Next ID mismatch with individual searches"; + assert reserveId < startReserveId, "Next ID must be below start ID"; + + // Ensure no active reserves exist between start and found ID + uint256 reserveIdBetween; + assert (reserveIdBetween < startReserveId && reserveIdBetween > reserveId) => + (!isBorrowing(reserveIdBetween) && !isUsingAsCollateral(reserveIdBetween)), "Skipped active reserve"; + + assert borrowing == isBorrowing(reserveId), "Borrowing flag mismatch"; + assert collateral == isUsingAsCollateral(reserveId), "Collateral flag mismatch"; + } +} + +/** + * @title Next Borrowing Search + * @notice Verifies that nextBorrowing correctly finds the nearest borrowing reserve below the start index. + * @link_property PositionStatusMap integrity + */ +rule nextBorrowing(uint256 startReserveId) { + uint256 NOT_FOUND = max_uint256; + require !isBorrowing(max_uint256); + + uint256 reserveId = nextBorrowing(startReserveId); + + if (reserveId != NOT_FOUND) { + assert reserveId < startReserveId, "Next ID must be below start ID"; + assert isBorrowing(reserveId), "Found reserve is not borrowing"; + + uint256 reserveIdBetween; + assert (reserveIdBetween < startReserveId && reserveIdBetween > reserveId) => !isBorrowing(reserveIdBetween), "Skipped borrowing reserve"; + } else { + uint256 anyId; + assert anyId < startReserveId => !isBorrowing(anyId), "Failed to find existing borrowing reserve"; + } +} + +/** + * @title Next Collateral Search + * @notice Verifies that nextCollateral correctly finds the nearest collateral reserve below the start index. + * @link_property PositionStatusMap integrity + */ +rule nextCollateral(uint256 startReserveId) { + uint256 NOT_FOUND = max_uint256; + require !isUsingAsCollateral(max_uint256); + + uint256 reserveId = nextCollateral(startReserveId); + + if (reserveId != NOT_FOUND) { + assert reserveId < startReserveId, "Next ID must be below start ID"; + assert isUsingAsCollateral(reserveId), "Found reserve is not using as collateral"; + + uint256 reserveIdBetween; + assert (reserveIdBetween < startReserveId && reserveIdBetween > reserveId) => !isUsingAsCollateral(reserveIdBetween), "Skipped collateral reserve"; + } else { + uint256 anyId; + assert anyId < startReserveId => !isUsingAsCollateral(anyId), "Failed to find existing collateral reserve"; + } +} + +/** + * @title Revert Safety + * @notice Ensures that all public/external functions in PositionStatusMap never revert. + * @link_property PositionStatusMap integrity + */ +rule neverReverts(method f) { + env e; + calldataarg args; + require e.msg.value == 0; + f@withrevert(e, args); + assert !lastReverted, "Function reverted"; +} diff --git a/certora/spec/libs/Premium.spec b/certora/spec/libs/Premium.spec new file mode 100644 index 000000000..d87d0674b --- /dev/null +++ b/certora/spec/libs/Premium.spec @@ -0,0 +1,46 @@ +/** + * @title Premium Library Specification + * @notice Formal verification of the Premium calculation logic. + * @dev This spec verifies functional equivalence between the Solidity implementation of calculatePremiumRay and its CVL representation. + * + * Verification Scope: + * - Functional equivalence: Ensuring calculatePremiumRay matches calculatePremiumRayCVL. + * - Precision and types: Verifying correct handling of signed offsets and unsigned indices. + */ + +//////////////////////////////////////////////////////////////////////////// +// METHODS // +//////////////////////////////////////////////////////////////////////////// + +methods { + function calculatePremiumRay(uint256 premiumShares, int256 premiumOffsetRay, uint256 drawnIndex) external returns (uint256) envfree; +} + +//////////////////////////////////////////////////////////////////////////// +// DEFINITIONS // +//////////////////////////////////////////////////////////////////////////// + +/** + * @title CVL Implementation of calculatePremiumRay + * @notice Symbolic representation of the premium calculation used for summarization. + */ +function calculatePremiumRayCVL(uint256 premiumShares, int256 premiumOffsetRay, uint256 drawnIndex) returns uint256 { + return require_uint256((premiumShares * drawnIndex) - premiumOffsetRay); +} + +//////////////////////////////////////////////////////////////////////////// +// RULES // +//////////////////////////////////////////////////////////////////////////// + +/** + * @title calculatePremiumRay Equivalence + * @notice Verifies that the Solidity implementation of calculatePremiumRay matches the symbolic CVL implementation. + * @dev Solidity: ((premiumShares * drawnIndex).toInt256() - premiumOffsetRay).toUint256() + * @link_property Premium library integrity + */ +rule calculatePremiumRay_equivalence(uint256 premiumShares, int256 premiumOffsetRay, uint256 drawnIndex) { + uint256 solidityResult = calculatePremiumRay(premiumShares, premiumOffsetRay, drawnIndex); + uint256 cvlResult = calculatePremiumRayCVL(premiumShares, premiumOffsetRay, drawnIndex); + + assert solidityResult == cvlResult, "Functional equivalence mismatch"; +} diff --git a/certora/spec/libs/SharesMath.spec b/certora/spec/libs/SharesMath.spec new file mode 100644 index 000000000..6d83acda9 --- /dev/null +++ b/certora/spec/libs/SharesMath.spec @@ -0,0 +1,161 @@ +/** + * @title SharesMath Library Specification + * @notice Formal verification of mathematical properties for asset-to-share and share-to-asset conversions. + * @dev This spec verifies monotonicity and additivity for rounding-up and rounding-down conversion functions. + * + * Verification Scope: + * - Monotonicity: Ensuring larger inputs always result in larger or equal outputs. + * - Additivity: Verifying that the sum of parts relates correctly to the whole, accounting for state changes. + * - Non-zero integrity: Ensuring that non-zero assets always result in non-zero shares when rounding up. + */ + +import "../HubBase.spec"; + +//////////////////////////////////////////////////////////////////////////// +// METHODS // +//////////////////////////////////////////////////////////////////////////// + +methods { + // envfree functions + function toSharesDown(uint256 assets, uint256 totalAssets, uint256 totalShares) external returns (uint256) envfree; + function toAssetsDown(uint256 shares, uint256 totalAssets, uint256 totalShares) external returns (uint256) envfree; + function toSharesUp(uint256 assets, uint256 totalAssets, uint256 totalShares) external returns (uint256) envfree; + function toAssetsUp(uint256 shares, uint256 totalAssets, uint256 totalShares) external returns (uint256) envfree; +} + +//////////////////////////////////////////////////////////////////////////// +// RULES // +//////////////////////////////////////////////////////////////////////////// + +/** + * @title toSharesUp Monotonicity + * @notice Verifies that larger asset amounts result in larger or equal share amounts when rounding up. + * @link_property ShareMath integrity + */ +rule toSharesUp_monotonicity(uint256 x, uint256 y) { + uint256 totalAssets; uint256 totalShares; + require totalAssets >= totalShares; + + assert x < y => toSharesUp(x, totalAssets, totalShares) <= toSharesUp(y, totalAssets, totalShares), "Monotonicity violation"; + satisfy x < y && toSharesUp(x, totalAssets, totalShares) == toSharesUp(y, totalAssets, totalShares); +} + +/** + * @title toSharesUp Additivity + * @notice Verifies that toSharesUp(x) + toSharesUp(y) >= toSharesUp(x+y), accounting for supply/asset changes. + * @link_property ShareMath integrity + */ +rule toSharesUp_additivity(uint256 x, uint256 y) { + uint256 totalAssets; uint256 totalShares; + require totalAssets >= totalShares; + require totalAssets >= x + y; + + uint256 sharesForX = toSharesUp(x, totalAssets, totalShares); + uint256 sharesForYAfterX = toSharesUp(y, require_uint256(totalAssets - x), require_uint256(totalShares - sharesForX)); + uint256 sharesForXplusY = toSharesUp(require_uint256(x + y), totalAssets, totalShares); + + assert sharesForXplusY <= (sharesForX + sharesForYAfterX), "Additivity violation (upper bound)"; + satisfy sharesForXplusY == (sharesForX + sharesForYAfterX); + satisfy sharesForXplusY < (sharesForX + sharesForYAfterX); +} + +/** + * @title toSharesUp Non-Zero Integrity + * @notice Ensures that any non-zero asset amount results in at least one share when rounding up. + * @link_property ShareMath integrity + */ +rule toSharesUp_nonZero(uint256 x) { + uint256 totalAssets; uint256 totalShares; + require totalAssets >= totalShares; + + uint256 sharesForX = toSharesUp(x, totalAssets, totalShares); + assert sharesForX == 0 <=> x == 0, "Non-zero assets must result in non-zero shares"; + satisfy x == 0; + satisfy x != 0; +} + +/** + * @title toAssetsUp Monotonicity + * @notice Verifies that larger share amounts result in larger or equal asset amounts when rounding up. + * @link_property ShareMath integrity + */ +rule toAssetsUp_monotonicity(uint256 x, uint256 y) { + uint256 totalAssets; uint256 totalShares; + require totalAssets >= totalShares; + + assert x < y => toAssetsUp(x, totalAssets, totalShares) < toAssetsUp(y, totalAssets, totalShares), "Monotonicity violation"; +} + + +/** + * @title toAssetsUp Additivity + * @notice Verifies that toAssetsUp(x) + toAssetsUp(y) >= toAssetsUp(x+y), accounting for supply/asset changes. + * @link_property ShareMath integrity + */ +rule toAssetsUp_additivity(uint256 x, uint256 y) { + uint256 totalAssets; uint256 totalShares; + require totalAssets >= totalShares; + + uint256 assetsForX = toAssetsUp(x, totalAssets, totalShares); + uint256 assetsForYAfterX = toAssetsUp(y, require_uint256(totalAssets + assetsForX), require_uint256(totalShares + x)); + uint256 assetsForXplusY = toAssetsUp(require_uint256(x + y), totalAssets, totalShares); + + assert assetsForXplusY <= assetsForX + assetsForYAfterX, "Additivity violation (upper bound)"; +} + +/** + * @title toSharesDown Monotonicity + * @notice Verifies that larger asset amounts result in larger or equal share amounts when rounding down. + * @link_property ShareMath integrity + */ +rule toSharesDown_monotonicity(uint256 x, uint256 y) { + uint256 totalAssets; uint256 totalShares; + require totalAssets >= totalShares; + + assert x < y => toSharesDown(x, totalAssets, totalShares) <= toSharesDown(y, totalAssets, totalShares), "Monotonicity violation"; + satisfy x < y && toSharesDown(x, totalAssets, totalShares) == toSharesDown(y, totalAssets, totalShares); +} + +/** + * @title toSharesDown Additivity + * @notice Verifies that toSharesDown(x) + toSharesDown(y) <= toSharesDown(x+y), accounting for supply/asset changes. + * @link_property ShareMath integrity + */ +rule toSharesDown_additivity(uint256 x, uint256 y) { + uint256 totalAssets; uint256 totalShares; + require totalAssets >= totalShares; + + uint256 sharesForX = toSharesDown(x, totalAssets, totalShares); + uint256 sharesForYAfterX = toSharesDown(y, require_uint256(totalAssets + x), require_uint256(totalShares + sharesForX)); + uint256 sharesForXplusY = toSharesDown(require_uint256(x + y), totalAssets, totalShares); + + assert sharesForXplusY >= sharesForX + sharesForYAfterX, "Additivity violation (lower bound)"; +} + +/** + * @title toAssetsDown Monotonicity + * @notice Verifies that larger share amounts result in larger or equal asset amounts when rounding down. + * @link_property ShareMath integrity + */ +rule toAssetsDown_monotonicity(uint256 x, uint256 y) { + uint256 totalAssets; uint256 totalShares; + require totalAssets >= totalShares; + + assert x < y => toAssetsDown(x, totalAssets, totalShares) <= toAssetsDown(y, totalAssets, totalShares), "Monotonicity violation"; +} + +/** + * @title toAssetsDown Additivity + * @notice Verifies that toAssetsDown(x) + toAssetsDown(y) <= toAssetsDown(x+y), accounting for supply/asset changes. + * @link_property ShareMath integrity + */ +rule toAssetsDown_additivity(uint256 x, uint256 y) { + uint256 totalAssets; uint256 totalShares; + require totalAssets >= totalShares; + + uint256 assetsForX = toAssetsDown(x, totalAssets, totalShares); + uint256 assetsForYAfterX = toAssetsDown(y, require_uint256(totalAssets + assetsForX), require_uint256(totalShares + x)); + uint256 assetsForXplusY = toAssetsDown(require_uint256(x + y), totalAssets, totalShares); + + assert assetsForXplusY >= assetsForX + assetsForYAfterX, "Additivity violation (lower bound)"; +} diff --git a/certora/spec/libs/SpokeUtils_toValue.spec b/certora/spec/libs/SpokeUtils_toValue.spec new file mode 100644 index 000000000..015d43f0a --- /dev/null +++ b/certora/spec/libs/SpokeUtils_toValue.spec @@ -0,0 +1,36 @@ +/** + * @title SpokeUtils Library Specification + * @notice Formal verification of SpokeUtils.toValue function. + * @dev This spec verifies functional equivalence between the Solidity implementation of toValue and its CVL representation. + * + * Verification Scope: + * - Functional equivalence: Ensuring toValue matches toValueCVL for valid input ranges. + * - Precision handling: Verifying correct conversion of asset amounts to Value units. + */ + +import "../common.spec"; + +//////////////////////////////////////////////////////////////////////////// +// METHODS // +//////////////////////////////////////////////////////////////////////////// + +methods { + function toValue(uint256 amount, uint256 decimals, uint256 price) external returns (uint256) envfree; +} + +//////////////////////////////////////////////////////////////////////////// +// RULES // +//////////////////////////////////////////////////////////////////////////// + +/** + * @title Verify that SpokeUtils.toValue matches toValueCVL + * @link_property SpokeUtils integrity + */ +rule checkToValueEquivalence(uint256 amount, uint256 decimals, uint256 price) { + require decimals >= 6 && decimals <= 18; + + uint256 resultSolidity = toValue(amount, decimals, price); + uint256 resultCVL = toValueCVL(amount, decimals, price); + + assert resultSolidity == resultCVL; +} diff --git a/certora/spec/symbolicRepresentation/ERC20s_CVL.spec b/certora/spec/symbolicRepresentation/ERC20s_CVL.spec new file mode 100644 index 000000000..9d7471826 --- /dev/null +++ b/certora/spec/symbolicRepresentation/ERC20s_CVL.spec @@ -0,0 +1,100 @@ + +/** +@title This file represents multiple erc20 tokens. +The functionality it summarize: +- balanceOf +- transfer +- transferFrom + +it simulates the behavior of erc20 tokens including reverting/returning false cases. +**/ +methods { + function _.transfer(address to, uint256 amount) external with (env e) + => transferCVL(calledContract, e.msg.sender, to, amount) expect bool; + function _.transferFrom(address from, address to, uint256 amount) external with (env e) + => transferFromCVL(calledContract, e.msg.sender, from, to, amount) expect bool; + + function _.safeTransfer(address token, address to, uint256 amount) internal with (env e) + => safeTransferCVL(token, executingContract, to, amount) expect bool; + function _.safeTransferFrom(address token, address from, address to, uint256 amount) internal with (env e) + => safeTransferFromCVL(token, executingContract, from, to, amount) expect bool; + function _.balanceOf(address account) external => + tokenBalanceOf(calledContract, account) expect uint256; + function _.balanceOf(address token, address account) internal => balanceByToken[token][account] expect uint256; + + function _.permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external => NONDET ALL; + +} + + + +/// CVL simple implementations of IERC20: +/// token => account => balance +ghost mapping(address => mapping(address => uint256)) balanceByToken; +/// token => owner => spender => allowance +ghost mapping(address => mapping(address => mapping(address => uint256))) allowanceByToken; + + +function tokenBalanceOf(address token, address account) returns uint256 { + return balanceByToken[token][account]; +} + + +function revertOn(bool b) { + if(b) { + revert(); + } +} + +function transferFromCVL(address token, address spender, address from, address to, uint256 amount) returns bool { + revertOn(allowanceByToken[token][from][spender] < amount); + bool success = transferCVL(token, from, to, amount); + if(success) { + allowanceByToken[token][from][spender] = assert_uint256(allowanceByToken[token][from][spender] - amount); + } + return success; +} + +ghost bool revertOrReturnFalse; +function transferCVL(address token, address from, address to, uint256 amount) returns bool { + revertOn(token == 0); + + if (balanceByToken[token][from] < amount) { + if(revertOrReturnFalse) { + revert(); + } + else { + 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; +} + +function safeTransferCVL(address token, address from, address to, uint256 amount) returns bool { + if (balanceByToken[token][from] < amount) { + revert(); + } + balanceByToken[token][from] = require_uint256(balanceByToken[token][from] - amount); + balanceByToken[token][to] = require_uint256(balanceByToken[token][to] + amount); // We neglect overflows. + + return true; +} + +function safeTransferFromCVL(address token, address spender, address from, address to, uint256 amount) returns bool { + bool success = safeTransferCVL(token, from, to, amount); + if (allowanceByToken[token][from][spender] < amount){ + revert(); + } + allowanceByToken[token][from][spender] = require_uint256(allowanceByToken[token][from][spender] - amount); + return true; +} \ No newline at end of file diff --git a/certora/spec/symbolicRepresentation/Math_CVL.spec b/certora/spec/symbolicRepresentation/Math_CVL.spec new file mode 100644 index 000000000..5810938f1 --- /dev/null +++ b/certora/spec/symbolicRepresentation/Math_CVL.spec @@ -0,0 +1,107 @@ + +/* + Returns floor(x * y / z) + Reverts when z==0 or x*y overflows +*/ +function mulDivDownCVL(uint256 x, uint256 y, uint256 z) returns uint256 { + mathint mul = x * y; + if (z == 0 || mul > max_uint256) { + revert(); + } + mathint res = (mul / z); + return require_uint256(res); +} + +/* + Returns ceil(x * y / z) + Reverts when z==0 or x*y or (x*y + z-1) overflows +*/ + +function mulDivUpCVL(uint256 x, uint256 y, uint256 z) returns uint256 { + mathint mul = x * y; + if (z == 0 || mul > max_uint256) { + revert(); + } + mathint res = ((mul + z - 1) / z); + if (res > max_uint256) + revert(); + return require_uint256(res); +} + +/* +Return ceil(x / y) +Reverts when y==0 or x overflows +*/ +function divUpCVL(uint256 x, uint256 y) returns uint256 { + if (y == 0) { + revert(); + } + mathint res = (x + y - 1) / y; + if (res > max_uint256) + revert(); + return require_uint256(res); +} + + +/* + Returns floor(x * y / z) + Reverts when z==0 or x*y overflows +*/ +function mulDivRayDownCVL(uint256 x, uint256 y) returns uint256 { + mathint mul = x * y; + if ( mul > max_uint256) { + revert(); + } + mathint res = (mul / (10 ^ 27)); + return require_uint256(res); +} + +/* + Returns ceil(x * y / z) + Reverts when z==0 or x*y or (x*y + z-1) overflows +*/ + +function mulDivRayUpCVL(uint256 x, uint256 y) returns uint256 { + mathint mul = x * y; + if ( mul > max_uint256) { + revert(); + } + mathint res = ((mul + (10 ^ 27) - 1) / (10 ^ 27)); + if (res > max_uint256) + revert(); + return require_uint256(res); +} + + +/* + returns ceil(x / RAY). +*/ + +function divRayUpCVL(uint256 x) returns uint256 { + mathint res = ((x + (10 ^ 27) - 1) / (10 ^ 27)); + if (res > max_uint256) + revert(); + return require_uint256(res); +} + +/* + returns x * RAY. +*/ +function mulRayCVL(uint256 x) returns uint256 { + mathint res = x * (10 ^ 27); + if (res > max_uint256) + revert(); + return require_uint256(res); +} + +function mulDivCVL(uint256 x, uint256 y, uint256 denominator, Math.Rounding rounding) returns uint256 { + if (denominator == 0) { + revert(); + } + mathint product = x * y; + if (rounding == Math.Rounding.Ceil) { + return require_uint256((product + denominator - 1) / denominator); + } else { // Math.Rounding.Floor + return require_uint256(product / denominator); + } +} diff --git a/certora/spec/symbolicRepresentation/SymbolicHub.spec b/certora/spec/symbolicRepresentation/SymbolicHub.spec new file mode 100644 index 000000000..fe8493e30 --- /dev/null +++ b/certora/spec/symbolicRepresentation/SymbolicHub.spec @@ -0,0 +1,157 @@ +import "./ERC20s_CVL.spec"; + +methods { + function _.previewRemoveByShares(uint256 assetId, uint256 shares) external with (env e) => previewRemoveBySharesCVL(assetId, shares, e) expect uint256; + + function _.previewAddByAssets(uint256 assetId, uint256 assets) external with (env e) => previewAddByAssetsCVL(assetId, assets, e) expect uint256; + + function _.previewAddByShares(uint256 assetId, uint256 shares) external with (env e) => previewAddBySharesCVL(assetId, shares, e) expect uint256; + + function _.previewRemoveByAssets(uint256 assetId, uint256 assets) external with (env e) => previewRemoveByAssetsCVL(assetId, assets, e) expect uint256; + + function _.previewDrawByShares(uint256 assetId, uint256 shares) external with (env e) => previewDrawBySharesCVL(assetId, shares, e) expect uint256; + + function _.previewRestoreByShares(uint256 assetId, uint256 shares) external with (env e) => previewRestoreBySharesCVL(assetId, shares, e) expect uint256; + + function _.getAssetDrawnIndex(uint256 assetId) external with (env e) => getAssetDrawnIndexCVL(assetId, e) expect uint256; + + +// Supply Operations + function _.add(uint256 assetId, uint256 amount) external with (env e) => addSummaryCVL(assetId, amount, e) expect uint256; +// Withdraw Operations + function _.remove(uint256 assetId, uint256 amount, address to) external with (env e) => removeSummaryCVL(calledContract, assetId, amount, to, e) expect uint256; +// Borrow Operations + function _.draw(uint256 assetId, uint256 amount, address to) external with (env e) => drawSummaryCVL(calledContract, assetId, amount, to, e) expect uint256; +// Repay Operations + function _.restore(uint256 assetId, uint256 drawnAmount, IHubBase.PremiumDelta premiumDelta) external with (env e) => restoreSummaryCVL(assetId, drawnAmount, premiumDelta, e) expect uint256; +// Report Deficit Operations + function _.reportDeficit(uint256 assetId, uint256 drawnAmount, IHubBase.PremiumDelta premiumDelta) external with (env e) => reportDeficitSummaryCVL(calledContract, assetId, drawnAmount, premiumDelta, e) expect uint256; +// Eliminate Deficit Operations + function _.eliminateDeficit(uint256 assetId, uint256 amount, address spokeAddress) external with (env e) => previewRemoveByAssetsCVL(assetId, amount, e) expect uint256; +//refresh premium - does not change balances and does not return anything + function _.refreshPremium(uint256 assetId, IHubBase.PremiumDelta premiumDelta) external => NONDET; + +// Pay Fee Shares Operations + function _.payFeeShares(uint256 assetId, uint256 shares) external => NONDET; + + function _.getAssetUnderlyingAndDecimals(uint256 assetId) external => getAssetUnderlyingAndDecimalsCVL(assetId) expect (address, uint8); + +} + +// symbolic debt index: for each assetId and block timestamp there is an index +// the index is monotonic increasing +persistent ghost mapping(uint256 /*assetId */ => mapping(uint256 /* blockTimestamp */ => uint256)) indexOfAssetPerBlock { + axiom forall uint256 assetId. forall uint256 blockTimestamp. forall uint256 blockTimestamp2. + blockTimestamp < blockTimestamp2 => indexOfAssetPerBlock[assetId][blockTimestamp] <= indexOfAssetPerBlock[assetId][blockTimestamp2]; + axiom forall uint256 assetId. forall uint256 blockTimestamp. indexOfAssetPerBlock[assetId][blockTimestamp] >= RAY; +} + +// symbolic assets to share ratio: +persistent ghost mapping(uint256 /*assetId */ => mapping(uint256 /*blockTimestamp*/ => uint256)) shareToAssetsRatio { + axiom forall uint256 assetId. forall uint256 blockTimestamp. forall uint256 blockTimestamp2. + blockTimestamp < blockTimestamp2 => shareToAssetsRatio[assetId][blockTimestamp] <= shareToAssetsRatio[assetId][blockTimestamp2]; + // at least RAY assets per share + axiom forall uint256 assetId. forall uint256 blockTimestamp. shareToAssetsRatio[assetId][blockTimestamp] >= RAY; +} + +ghost mapping(uint256 /*assetId*/ => address /*underlying*/) assetUnderlying; + +ghost mapping(uint256 /*assetId*/ => uint8 /*decimals*/) assetDecimals; + +function getAssetUnderlyingAndDecimalsCVL(uint256 assetId) returns (address, uint8) { + return (assetUnderlying[assetId], assetDecimals[assetId]); +} + +// toAddedSharesDown : assets.toSharesDown(asset.totalAddedAssets(), asset.totalAddedShares()); +function previewAddByAssetsCVL(uint256 assetId, uint256 assets, env e) returns (uint256) { + uint256 ratio = shareToAssetsRatio[assetId][e.block.timestamp]; + return require_uint256((assets * RAY) / ratio); +} +function previewAddBySharesCVL(uint256 assetId, uint256 shares, env e) returns (uint256) { + uint256 ratio = shareToAssetsRatio[assetId][e.block.timestamp]; + return require_uint256((shares * ratio + RAY - 1) / RAY); +} + +// toAddedAssetsDown : shares.toAssetsDown(asset.totalAddedAssets(), asset.totalAddedShares()); +function previewRemoveBySharesCVL(uint256 assetId, uint256 shares, env e) returns (uint256) { + uint256 ratio = shareToAssetsRatio[assetId][e.block.timestamp]; + return require_uint256(shares * ratio / RAY); +} + +// toAddedSharesUp :assets.toSharesUp(asset.totalAddedAssets(), asset.totalAddedShares()); +function previewRemoveByAssetsCVL(uint256 assetId, uint256 assets, env e) returns (uint256) { + uint256 ratio = shareToAssetsRatio[assetId][e.block.timestamp]; + return require_uint256(((assets * RAY) + ratio -1) / ratio); +} + +// toDrawnAssetsDown : shares.rayMulDown(asset.getDrawnIndex()) +function previewDrawBySharesCVL(uint256 assetId, uint256 shares, env e) returns (uint256) { + uint256 ratio = indexOfAssetPerBlock[assetId][e.block.timestamp]; + return require_uint256((shares * ratio) / RAY); +} + +// toDrawnSharesUp : assets.rayDivUp(asset.getDrawnIndex()) +function previewDrawByAssetsCVL(uint256 assetId, uint256 assets, env e) returns (uint256) { + uint256 ratio = indexOfAssetPerBlock[assetId][e.block.timestamp]; + return require_uint256(((assets * RAY) + ratio -1) / ratio); + +} +// toDrawnAssetsUp : shares.rayMulUp(asset.getDrawnIndex()); +function previewRestoreBySharesCVL(uint256 assetId, uint256 shares, env e) returns (uint256) { + uint256 ratio = indexOfAssetPerBlock[assetId][e.block.timestamp]; + return require_uint256(((shares * ratio) + RAY - 1) / RAY); +} + +// toDrawnSharesDown : assets.rayDivDown(asset.getDrawnIndex()); +function previewRestoreByAssetsCVL(uint256 assetId, uint256 assets, env e) returns (uint256) { + uint256 ratio = indexOfAssetPerBlock[assetId][e.block.timestamp]; + return require_uint256((assets * RAY) / ratio); +} + +// getAssetDrawnIndex: returns the drawn index for an asset at a given block timestamp +function getAssetDrawnIndexCVL(uint256 assetId, env e) returns (uint256) { + return indexOfAssetPerBlock[assetId][e.block.timestamp]; +} + +// CVL function summarizations for Hub operations with zero amount checks and reflecting balance changes + +function addSummaryCVL(uint256 assetId, uint256 amount, env e) returns (uint256) { + require amount > 0; + // Return computed shares based on amount and asset using existing preview function + uint256 shares = previewAddByAssetsCVL(assetId, amount, e); + require shares > 0; // rule nothingForZero_add + return shares; +} + +function removeSummaryCVL(address hub,uint256 assetId, uint256 amount, address to, env e) returns (uint256) { + require amount > 0; + // Return computed shares based on amount and asset using existing preview function + uint256 shares = previewRemoveByAssetsCVL(assetId, amount, e); + require shares > 0; // rule nothingForZero_remove + // update balanceOf to + safeTransferCVL(assetUnderlying[assetId], hub, to, amount); + return shares; +} + +function drawSummaryCVL(address hub, uint256 assetId, uint256 amount, address to, env e) returns (uint256) { + require amount > 0; + // Return computed drawn shares based on amount and asset using existing preview function + safeTransferCVL(assetUnderlying[assetId], hub, to, amount); + return previewDrawByAssetsCVL(assetId, amount, e); +} + +function restoreSummaryCVL(uint256 assetId, uint256 drawnAmount, IHubBase.PremiumDelta premiumDelta, env e) returns (uint256) { + + // Return computed restored shares based on drawn amount using existing preview function + return previewRestoreByAssetsCVL(assetId, drawnAmount, e); +} + +persistent ghost bool deficitReportedFlag; +function reportDeficitSummaryCVL(address hub, uint256 assetId, uint256 drawnAmount, IHubBase.PremiumDelta premiumDelta, env e) returns (uint256) { + deficitReportedFlag = true; + // Return computed restored shares based on drawn amount using existing preview function + return previewRestoreByAssetsCVL(assetId, drawnAmount, e); + +} + + diff --git a/certora/spec/symbolicRepresentation/SymbolicPositionStatus.spec b/certora/spec/symbolicRepresentation/SymbolicPositionStatus.spec new file mode 100644 index 000000000..f4685f3c1 --- /dev/null +++ b/certora/spec/symbolicRepresentation/SymbolicPositionStatus.spec @@ -0,0 +1,128 @@ +/** +Symbolic representation of the PositionStatusMap.sol library. +The summarization of the PositionStatus.spec library is verified to obtain the rules of the PositionStatusMap.sol library. + +This summary is used in the Spoke.spec file. + +To run this spec file: + certoraRun certora/conf/VerifySymbolicPositionStatus.conf + + +**/ + +methods { + function _.setBorrowing(ISpoke.PositionStatus storage positionStatus, uint256 reserveId, bool borrowing) internal => setBorrowingCVL(reserveId, borrowing) expect void; + + function _.setUsingAsCollateral(ISpoke.PositionStatus storage positionStatus, uint256 reserveId, bool usingAsCollateral) internal => setUsingAsCollateralCVL(reserveId, usingAsCollateral) expect void; + + function _.isUsingAsCollateralOrBorrowing(ISpoke.PositionStatus storage positionStatus, uint256 reserveId) internal => isUsingAsCollateralOrBorrowingCVL(reserveId) expect bool; + + function _.isBorrowing(ISpoke.PositionStatus storage positionStatus, uint256 reserveId) internal => isBorrowingCVL(reserveId) expect bool; + + function _.isUsingAsCollateral(ISpoke.PositionStatus storage positionStatus, uint256 reserveId) internal => isUsingAsCollateralCVL(reserveId) expect bool; + + function _.collateralCount(ISpoke.PositionStatus storage positionStatus, uint256 reserveCount) internal => collateralCountCVL(reserveCount) expect uint256; + + function _.next(ISpoke.PositionStatus storage positionStatus, uint256 startReserveId) internal => nextCVL(startReserveId) expect (uint256, bool, bool); + + function _.nextBorrowing(ISpoke.PositionStatus storage positionStatus, uint256 startReserveId) internal => nextBorrowingCVL(startReserveId) expect uint256; + + function _.nextCollateral(ISpoke.PositionStatus storage positionStatus, uint256 startReserveId) internal => nextCollateralCVL(startReserveId) expect uint256; +} + +///@dev the user which is updated +// it is safe to assume that there is only one user involved in each function call +// see SpokeUserIntegrity.spec rule userIntegrity +persistent ghost address userGhost; + +///@dev ghost mapping of the borrowing flags for the user +ghost mapping(address /*user */ => mapping(uint256 /*reserveId*/ => bool /*borrowing*/)) isBorrowing { + init_state axiom forall address user. forall uint256 reserveId. !isBorrowing[user][reserveId]; + +} + +///@dev ghost mapping of the using as collateral flags for the user +ghost mapping(address /*user */ => mapping(uint256 /*reserveId*/ => bool /*usingAsCollateral*/)) isUsingAsCollateral { + init_state axiom forall address user. forall uint256 reserveId. !isUsingAsCollateral[user][reserveId]; +} + + + +persistent ghost uint256 reserveCountGhost { + init_state axiom reserveCountGhost == 0; +} + + +function setBorrowingCVL(uint256 reserveId, bool borrowing) { + isBorrowing[userGhost][reserveId] = borrowing; + +} + +function isBorrowingCVL(uint256 reserveId) returns (bool) { + return isBorrowing[userGhost][reserveId]; +} + +function setUsingAsCollateralCVL(uint256 reserveId, bool usingAsCollateral) { + if (usingAsCollateral != isUsingAsCollateral[userGhost][reserveId]) { + reserveCountGhost = require_uint256(usingAsCollateral ? reserveCountGhost + 1 : reserveCountGhost - 1); + } + isUsingAsCollateral[userGhost][reserveId] = usingAsCollateral; + } + + +function isUsingAsCollateralCVL(uint256 reserveId) returns (bool) { + return isUsingAsCollateral[userGhost][reserveId]; +} + +function isUsingAsCollateralOrBorrowingCVL(uint256 reserveId) returns (bool) { + return isUsingAsCollateral[userGhost][reserveId] || isBorrowing[userGhost][reserveId]; +} + + +function nextCVL(uint256 startReserveId) returns (uint256, bool, bool) { + uint256 result; + require (result < startReserveId) || result == max_uint256; + require (result < startReserveId) <=> (isUsingAsCollateral[userGhost][result] || isBorrowing[userGhost][result]); + if (result != max_uint256) { + require forall uint256 i. i < startReserveId && i > result => !isBorrowing[userGhost][i]; + require forall uint256 i. i < startReserveId && i > result => !isUsingAsCollateral[userGhost][i]; + } else { + require forall uint256 i. i < startReserveId => !isBorrowing[userGhost][i]; + require forall uint256 i. i < startReserveId => !isUsingAsCollateral[userGhost][i]; + } + return (result, isBorrowing[userGhost][result], isUsingAsCollateral[userGhost][result]); +} + +function nextBorrowingCVL(uint256 startReserveId) returns (uint256) { + uint256 result; + require result < startReserveId || result == max_uint256; + require isBorrowing[userGhost][result] || result == max_uint256; + require !isBorrowing[userGhost][max_uint256]; + if (result != max_uint256) { + require forall uint256 i. i < startReserveId && i > result => !isBorrowing[userGhost][i]; + } else { + // no more bits are set + require forall uint256 i. i < startReserveId => !isBorrowing[userGhost][i]; + } + return result; +} + +function nextCollateralCVL(uint256 startReserveId) returns (uint256) { + uint256 result; + require result < startReserveId || result == max_uint256; + require isUsingAsCollateral[userGhost][result] || result == max_uint256; + + require !isUsingAsCollateral[userGhost][max_uint256]; + if (result != max_uint256) { + require forall uint256 i. i < startReserveId && i > result => !isUsingAsCollateral[userGhost][i]; + } else { + // no more bits are set + require forall uint256 i. i < startReserveId => !isUsingAsCollateral[userGhost][i]; + } + return result; +} + +function collateralCountCVL(uint256 ignore) returns (uint256) { + return reserveCountGhost; +} + diff --git a/certora/spec/symbolicRepresentation/VerifySymbolicPositionStatus.spec b/certora/spec/symbolicRepresentation/VerifySymbolicPositionStatus.spec new file mode 100644 index 000000000..94d09318c --- /dev/null +++ b/certora/spec/symbolicRepresentation/VerifySymbolicPositionStatus.spec @@ -0,0 +1,19 @@ +/** +Verification of the summarization of the PositionStatus.spec library. +All rules of PositionStatus.spec are verified to hold also on the summarization . + +To run this spec file: + certoraRun certora/conf/VerifySymbolicPositionStatus.conf +**/ + +import "./SymbolicPositionStatus.spec"; +import "../libs/PositionStatus.spec"; + +use rule setBorrowing; +use rule setUsingAsCollateral; +use rule isUsingAsCollateralOrBorrowing; +use rule collateralCount; +use rule next; +use rule nextBorrowing; +use rule nextCollateral; +