diff --git a/.github/workflows/invariants.yml b/.github/workflows/invariants.yml new file mode 100644 index 000000000..4115f03f3 --- /dev/null +++ b/.github/workflows/invariants.yml @@ -0,0 +1,121 @@ +name: Invariants + +on: + push: + branches: + - main + pull_request: + +env: + FOUNDRY_PROFILE: invariant + +permissions: + contents: read + +# ! todo uncomment before merging +# concurrency: +# group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} +# cancel-in-progress: true + +jobs: + echidna: + name: Echidna (${{ matrix.mode }}) + runs-on: ubuntu-latest + + strategy: + matrix: + mode: [property, assertion, exploration] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Run Foundry setup + uses: bgd-labs/github-workflows/.github/actions/foundry-setup@main + with: + FOUNDRY_VERSION: nightly + + - name: Run Forge Build + run: forge build --build-info + + - name: Run Echidna ${{ matrix.mode }} Mode + uses: crytic/echidna-action@v2 + with: + files: invariants/protocol-suite/Tester.t.sol + contract: Tester + config: invariants/protocol-suite/_config/echidna_config_ci.yaml + test-mode: ${{ matrix.mode }} + + - name: Upload Echidna corpus + uses: actions/upload-artifact@v4 + with: + name: echidna-corpus-protocol-${{ matrix.mode }} + path: corpus/ + + medusa: + name: Medusa + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Run Foundry setup + uses: bgd-labs/github-workflows/.github/actions/foundry-setup@main + with: + FOUNDRY_VERSION: nightly + + - name: Run Forge Build + run: forge build --build-info + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: stable + cache: false + + - name: Install slither-analyzer + run: pip install slither-analyzer + + - name: Install Medusa + run: go install github.com/crytic/medusa@latest + + - name: Run Medusa + run: medusa fuzz --config invariants/protocol-suite/_config/medusa_config_ci.json + + - name: Upload Medusa corpus + uses: actions/upload-artifact@v4 + with: + name: medusa-corpus-protocol + path: corpus/ + + foundry: + name: Foundry + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Run Foundry setup + uses: bgd-labs/github-workflows/.github/actions/foundry-setup@main + with: + FOUNDRY_VERSION: nightly + + - name: Run Forge Build + run: forge build --build-info + + - name: Run Forge Test + run: forge test --mc TesterFoundry -vvv --show-progress + + - name: Upload Foundry corpus + uses: actions/upload-artifact@v4 + with: + name: foundry-corpus-protocol + path: invariants/protocol-suite/_corpus/foundry diff --git a/.github/workflows/invariants_hub.yml b/.github/workflows/invariants_hub.yml new file mode 100644 index 000000000..8e6103a60 --- /dev/null +++ b/.github/workflows/invariants_hub.yml @@ -0,0 +1,96 @@ +name: Invariants-Hub + +on: + push: + branches: + - main + pull_request: + +env: + FOUNDRY_PROFILE: invariant + +permissions: + contents: read + +# ! todo uncomment before merging +# concurrency: +# group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} +# cancel-in-progress: true + +jobs: + echidna: + name: Echidna (${{ matrix.mode }}) + runs-on: ubuntu-latest + + strategy: + matrix: + mode: [property, assertion, exploration] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Run Foundry setup + uses: bgd-labs/github-workflows/.github/actions/foundry-setup@main + with: + FOUNDRY_VERSION: nightly + + - name: Run Forge Build + run: forge build --build-info + + - name: Run Echidna ${{ matrix.mode }} Mode + uses: crytic/echidna-action@v2 + with: + files: invariants/hub-suite/Tester.t.sol + contract: Tester + config: invariants/hub-suite/_config/echidna_config_ci.yaml + test-mode: ${{ matrix.mode }} + + - name: Upload Echidna corpus + uses: actions/upload-artifact@v4 + if: always() + with: + name: echidna-corpus-hub-${{ matrix.mode }} + path: corpus/ + + medusa: + name: Medusa + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Run Foundry setup + uses: bgd-labs/github-workflows/.github/actions/foundry-setup@main + with: + FOUNDRY_VERSION: nightly + + - name: Run Forge Build + run: forge build --build-info + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: stable + cache: false + + - name: Install slither-analyzer + run: pip install slither-analyzer + + - name: Install Medusa + run: go install github.com/crytic/medusa@latest + + - name: Run Medusa + run: medusa fuzz --config invariants/hub-suite/_config/medusa_config_ci.json + + - name: Upload Medusa corpus + uses: actions/upload-artifact@v4 + if: always() + with: + name: medusa-corpus-hub + path: corpus/ diff --git a/.gitignore b/.gitignore index c92814bc4..85917783f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,11 @@ lcov* report/ .DS_Store + +# Invariant testing artifacts +_corpus/ +crytic-export/ +slither_results.json + .venv/ +.mcp.json diff --git a/Makefile b/Makefile index 9081609b9..1b1df44ca 100644 --- a/Makefile +++ b/Makefile @@ -29,3 +29,38 @@ coverage : make coverage-clean make coverage-report make coverage-badge + +# Echidna +echidna: + FOUNDRY_PROFILE=invariant echidna . --contract invariants/protocol-suite/Tester.t.sol:Tester --config ./invariants/protocol-suite/_config/echidna_config.yaml +echidna-assert: + FOUNDRY_PROFILE=invariant echidna . --contract invariants/protocol-suite/Tester.t.sol:Tester --test-mode assertion --config ./invariants/protocol-suite/_config/echidna_config.yaml +echidna-explore: + FOUNDRY_PROFILE=invariant echidna . --contract invariants/protocol-suite/Tester.t.sol:Tester --test-mode exploration --config ./invariants/protocol-suite/_config/echidna_config.yaml + +echidna-hub: + FOUNDRY_PROFILE=invariant echidna . --contract invariants/hub-suite/Tester.t.sol:Tester --config ./invariants/hub-suite/_config/echidna_config.yaml +echidna-hub-assert: + FOUNDRY_PROFILE=invariant echidna . --contract invariants/hub-suite/Tester.t.sol:Tester --test-mode assertion --config ./invariants/hub-suite/_config/echidna_config.yaml +echidna-hub-explore: + FOUNDRY_PROFILE=invariant echidna . --contract invariants/hub-suite/Tester.t.sol:Tester --test-mode exploration --config ./invariants/hub-suite/_config/echidna_config.yaml + +# Medusa +medusa: + FOUNDRY_PROFILE=invariant medusa fuzz --config ./medusa.protocol.json +medusa-hub: + FOUNDRY_PROFILE=invariant medusa fuzz --config ./medusa.hub.json + +foundry-invariants: + FOUNDRY_PROFILE=invariant forge test --mc TesterFoundry -vvv + +# Results +runes-echidna: + runes convert ./invariants/protocol-suite/_corpus/echidna/default/_data/corpus/reproducers --output ./invariants/protocol-suite/replays +runes-medusa: + runes convert ./invariants/protocol-suite/_corpus/medusa/ --output ./invariants/protocol-suite/replays + +runes-echidna-hub: + runes convert ./invariants/hub-suite/_corpus/echidna/default/_data/corpus/reproducers --output ./invariants/hub-suite/replays +runes-medusa-hub: + runes convert ./invariants/hub-suite/_corpus/medusa/ --output ./invariants/hub-suite/replays diff --git a/foundry.toml b/foundry.toml index 4fdd4cf5b..c0e117e37 100644 --- a/foundry.toml +++ b/foundry.toml @@ -11,7 +11,7 @@ optimizer_runs = 444444444444 bytecode_hash = "none" gas_snapshot_check = false gas_limit = 1099511627776 -dynamic_test_linking = true +dynamic_test_linking = false # https://github.com/crytic/crytic-compile/issues/651#issuecomment-3813020679 additional_compiler_profiles = [ { name = "hub", optimizer = true, via_ir = true, optimizer_runs = 22_300 }, @@ -38,7 +38,7 @@ runs = 1000 seed = "0x640" [profile.pr.fuzz] -runs = 5000 +runs = 5 # todo revert [profile.ci.fuzz] runs = 10000 @@ -50,12 +50,38 @@ isolate = true [profile.coverage] optimizer = true -optimizer_runs = 444444444444 +optimizer_runs = 200 +via_ir = false +fuzz.runs = 5 +additional_compiler_profiles = [] +compilation_restrictions = [] + +[profile.invariant] +optimizer = true +optimizer_runs = 200 via_ir = false -fuzz.runs = 50 +test = 'invariants/' additional_compiler_profiles = [] compilation_restrictions = [] +[profile.invariant.invariant] +fail_on_revert = true +runs = 10000 +depth = 1000 +corpus_dir = "invariants/protocol-suite/_corpus/foundry" +show_solidity = true +show_metrics = true +show_edge_coverage = true + +[profile.invariant.fuzz] +seed = '0x1' +include_storage = true +include_push_bytes = true +call_override = false +dictionary_weight = 80 +shrink_sequence = true + + [rpc_endpoints] mainnet = "${RPC_MAINNET}" optimism = "${RPC_OPTIMISM}" diff --git a/fuzz_parser.py b/fuzz_parser.py new file mode 100644 index 000000000..fc8c7ce2b --- /dev/null +++ b/fuzz_parser.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +import re + +def parse_echidna_trace(trace): + """Parse Echidna trace and extract function calls, parameters, delays, and 'from' addresses.""" + calls = [] + last_address = None # Track the last 'from' address + + for line in trace.strip().splitlines(): + # Parse function calls, including those with underscores + func_call = re.match(r"Tester\.([a-zA-Z0-9_]+)\(([^)]*)\)", line) + if func_call: + func_name = func_call.group(1) + params = func_call.group(2) + + # Check for 'from' address + from_address = re.search(r"from: (0x[a-fA-F0-9]+)", line) + time_delay = re.search(r"Time delay: (\d+)", line) + + # If we have a 'from' address, we need to set it up + if from_address: + address = from_address.group(1) + if address != last_address: + # Only set up the actor if the address has changed + calls.append(f"_setUpActor({address});") + last_address = address + + # Add the delay if it exists + if time_delay: + delay_time = time_delay.group(1) + calls.append(f"_delay({delay_time});") + + # Add the function call + calls.append(f"Tester.{func_name}({params});") + + # Handle special case of "*wait*" for a delay + elif "*wait*" in line: + time_delay = re.search(r"Time delay: (\d+)", line) + if time_delay: + delay_time = time_delay.group(1) + calls.append(f"_delay({delay_time});") + + return calls + +def parse_medusa_trace(trace): + """Parse Medusa trace and extract function calls, parameters, block, time, and sender information.""" + calls = [] + last_address = None # Track the last 'from' address + last_block = 0 + last_time = 0 + + for line in trace.strip().splitlines(): + # Skip empty lines + if not line.strip(): + continue + + # Parse Medusa format: number) Contract.function(types)(values) (metadata) + medusa_pattern = r"(?:\d+\))?\s*Tester\.([a-zA-Z0-9_]+)\([^)]*\)\(([^)]*)\)\s*\(block=(\d+),\s*time=(\d+).*sender=(0x[a-fA-F0-9]+)\)" + match = re.search(medusa_pattern, line) + + if match: + func_name = match.group(1) + params = match.group(2) + block = int(match.group(3)) + time = int(match.group(4)) + address = match.group(5) + + if (address == "0x10000"): + actor = "USER1" + elif (address == "0x20000"): + actor = "USER2" + elif (address == "0x30000"): + actor = "USER3" + + # If sender address changed, set up the new actor + if address != last_address: + calls.append(f"_setUpActor({actor});") + last_address = address + + # Add block/time delay if needed + if time > last_time: + # Medusa reports absolute timestamps, so we calculate the difference + # between consecutive transactions to determine the delay + time_diff = time - last_time + if time_diff > 0: + calls.append(f"_delay({time_diff});") + last_time = time + last_block = block + + # Add the function call + calls.append(f"Tester.{func_name}({params});") + + return calls + +def detect_trace_format(trace): + """Detect whether the trace is in Echidna or Medusa format.""" + # Check for Medusa format indicators (block=X, time=Y, sender=0xZ) + if re.search(r"\(block=\d+,\s*time=\d+.*sender=0x[a-fA-F0-9]+\)", trace): + return "medusa" + # Default to Echidna format + return "echidna" + +def generate_foundry_test(calls, test_name="test_replay"): + """Generate the Solidity test function code.""" + test_code = [f"function {test_name}() public {{"] + test_code.extend(f" {call}" for call in calls) + test_code.append("}") + + return "\n".join(test_code) + +# Ask user to paste the trace +print("Paste your Echidna or Medusa call trace below. Press Enter twice to finish:") +trace = [] +while True: + line = input() + if line: + trace.append(line.strip()) + else: + break +trace = "\n".join(trace) + +# Detect format and parse the trace +format_type = detect_trace_format(trace) +if format_type == "medusa": + print("\nDetected Medusa trace format") + parsed_calls = parse_medusa_trace(trace) +else: + print("\nDetected Echidna trace format") + parsed_calls = parse_echidna_trace(trace) + +# Generate the test +solidity_test = generate_foundry_test(parsed_calls) + +# Output the generated Solidity test +print("\nGenerated Foundry Test Function:\n") +print(solidity_test) diff --git a/invariants/hub-suite/HandlerAggregator.t.sol b/invariants/hub-suite/HandlerAggregator.t.sol new file mode 100644 index 000000000..c4d1f9a4f --- /dev/null +++ b/invariants/hub-suite/HandlerAggregator.t.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +// Handler contracts +import {HubHandler} from './handlers/HubHandler.t.sol'; +import {HubConfiguratorHandler} from './handlers/HubConfiguratorHandler.t.sol'; +import {HubAdminHandler} from './handlers/HubAdminHandler.t.sol'; +import {DonationAttackHandler} from './handlers/simulators/DonationAttackHandler.t.sol'; + +/// @notice Helper contract to aggregate all handler contracts for hub suite +abstract contract HandlerAggregator is + HubHandler, + HubConfiguratorHandler, + HubAdminHandler, + DonationAttackHandler +{ + function _setUpHandlers() internal {} +} diff --git a/invariants/hub-suite/Invariants.t.sol b/invariants/hub-suite/Invariants.t.sol new file mode 100644 index 000000000..2671c952a --- /dev/null +++ b/invariants/hub-suite/Invariants.t.sol @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import {HubInvariants} from './invariants/HubInvariants.t.sol'; + +/// @title Invariants +/// @notice Aggregator for hub invariants +abstract contract Invariants is HubInvariants { + /////////////////////////////////////////////////////////////////////////////////////////////// + // ACCOUNTING // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function invariant_INV_HUB_ACCOUNTING() public returns (bool) { + uint256 assetCount = hub.getAssetCount(); + for (uint256 i; i < assetCount; i++) { + assert_INV_HUB_A(hub, i); + assert_INV_HUB_B(hub, i); + assert_INV_HUB_C(hub, i); + assert_INV_HUB_GH(hub, i); + assert_INV_HUB_O(hub, i); + assert_INV_HUB_P(hub, i); + } + return true; + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // SOLVENCY // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function invariant_INV_HUB_SOLVENCY() public returns (bool) { + uint256 assetCount = hub.getAssetCount(); + for (uint256 i; i < assetCount; i++) { + assert_INV_HUB_E(hub, i); + assert_INV_HUB_F(hub, i); + assert_INV_HUB_I(hub, i); + assert_INV_HUB_K(hub, i); + } + return true; + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // MONOTONICITY // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function invariant_INV_HUB_MONOTONICITY() public returns (bool) { + uint256 assetCount = hub.getAssetCount(); + for (uint256 i; i < assetCount; i++) { + assert_INV_HUB_Q(hub, i); + assert_INV_HUB_R(hub, i); + } + return true; + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // ERC4626 // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function invariant_INV_HUB_ERC4626() public returns (bool) { + uint256 assetCount = hub.getAssetCount(); + for (uint256 i; i < assetCount; i++) { + for (uint256 j; j < NUMBER_OF_ACTORS; j++) { + assert_INV_HUB_ERC4626_A(i, actors[j]); + assert_INV_HUB_ERC4626_B(i, actors[j]); + } + assert_INV_HUB_ERC4626_C(i); + assert_INV_HUB_ERC4626_D(i); + } + return true; + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // AVAILABILITY // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function invariant_INV_HUB_AVAILABILITY() public returns (bool) { + uint256 assetCount = hub.getAssetCount(); + for (uint256 i; i < assetCount; i++) { + assert_INV_HUB_AVAILABILITY_A(i); + assert_INV_HUB_AVAILABILITY_B(i); + assert_INV_HUB_AVAILABILITY_C(i); + assert_INV_HUB_AVAILABILITY_D(i); + assert_INV_HUB_AVAILABILITY_E(i); + for (uint256 j; j < NUMBER_OF_ACTORS; j++) { + assert_INV_HUB_AVAILABILITY_F(i, actors[j]); + assert_INV_HUB_AVAILABILITY_G(i, actors[j]); + assert_INV_HUB_AVAILABILITY_H(i, actors[j]); + assert_INV_HUB_AVAILABILITY_I(i, actors[j]); + } + } + return true; + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // REPLAY HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function _checkAllHubInvariants() internal { + assertTrue(invariant_INV_HUB_ACCOUNTING()); + assertTrue(invariant_INV_HUB_SOLVENCY()); + assertTrue(invariant_INV_HUB_MONOTONICITY()); + assertTrue(invariant_INV_HUB_ERC4626()); + assertTrue(invariant_INV_HUB_AVAILABILITY()); + } +} diff --git a/invariants/hub-suite/README.md b/invariants/hub-suite/README.md new file mode 100644 index 000000000..d49e0b374 --- /dev/null +++ b/invariants/hub-suite/README.md @@ -0,0 +1,93 @@ +# Hub-Focused Fuzzing & Invariant Testing Suite + +A handler-based invariant testing suite focused exclusively on the **Hub** component of the Aave v4 protocol. This suite performs deep stateful fuzzing against a single hub with actors simulating spokes, validating critical hub system properties through automated property checking and postcondition verification. + +Hub-suite is the **canonical source** for all hub-related specs and invariant assertions. The protocol-suite imports from hub-suite — hub-suite has zero protocol-suite dependencies. + +## Overview + +The suite tests a simplified hub-centric deployment with: + +- **1 Hub** with a single interest rate strategy +- **Multiple Actors** simulating spoke behavior (registered as spokes in the hub) +- **3 Base Assets** (USDC, WETH, WBTC) with varying decimals (6, 18, 8) +- **Direct Hub Interactions** through handlers that expose hub functions +- **Hub Configuration Management** through the HubConfigurator handler + +All protocol actions are monitored by hooks that snapshot state and verify postconditions after each transaction, enabling detection of invariant violations and edge cases specific to hub operations. + +## Architecture + +### Core Components + +**Setup Layer** (`Setup.t.sol`, `base/`) + +- Deploys a single Hub with a deterministic interest rate strategy +- Configures 3 base assets (USDC, WETH, WBTC) with varying decimals +- Initializes multiple actors with spoke permissions registered on the hub + +**Spec Layer** (`specs/`) + +- `HubInvariantsSpec` – canonical hub invariant string constants (`INV_HUB_*`, `ERC4626_*`, `AVAILABILITY_*`) +- `HubPostconditionsSpec` – canonical hub postcondition string constants (`GPOST_HUB_*`, `HSPOST_HUB_*`) +- Protocol-suite inherits these specs; they are defined here only + +**Handler Layer** (`handlers/`) + +- `HubHandler` – hub liquidity operations (add, remove, draw, restore, etc.) through actor-spokes +- `HubConfiguratorHandler` – admin operations (spoke cap updates, risk parameter changes) +- `DonationAttackHandler` – simulates direct token transfers to the hub + +**Invariant Layer** (`invariants/`) + +- `HubInvariantAssertions` – abstract parameterized hub invariant assertion logic (INV_HUB_A through R). Importable by other suites +- `HubInvariants` – concrete invariants extending `HubInvariantAssertions`, adds ERC4626 and AVAILABILITY assertions specific to the hub-suite + +**Verification Layer** (`hooks/`) + +- Before/after hooks with state snapshots +- Global and handler-specific postcondition assertions + +### Reuse by Protocol-Suite + +Hub-suite exports reusable abstracts that protocol-suite imports: + +``` +protocol-suite → hub-suite → shared/ +``` + +| Hub-suite export | Protocol-suite usage | +| ------------------------ | ------------------------------------------------------ | +| `HubInvariantsSpec` | Inherited by `InvariantsSpec` for hub string constants | +| `HubPostconditionsSpec` | Inherited by `PostconditionsSpec` for hub strings | +| `HubInvariantAssertions` | Inherited by `Invariants.t.sol` for hub assert logic | + +## How It Works + +1. **Fuzzer** generates random inputs and selects handler functions +2. **Handlers** execute hub operations through actor proxies (respects spoke roles) +3. **Hooks** capture snapshots of relevant hub state variables +4. **Postconditions** validate expected outcomes and state transitions +5. **Invariants** continuously checked across all hub states + +## Quick Start + +```bash +# Run fuzzing campaign with Medusa +make medusa-hub + +# Run with Echidna in assertion mode +make echidna-hub-assert +``` + +## Key Features + +- **Canonical hub logic** — single source of truth for hub specs and invariant assertions +- **Actor-based spoke simulation** – no custom spoke deployments, just actors as spokes +- **Comprehensive postcondition checking** after every hub state transition +- **Performance optimized** – minimal asset and spoke count for faster fuzzing +- **Reusable invariant abstracts** – `HubInvariantAssertions` importable by any suite + +--- + +**Note:** This suite complements the full multi-hub, multi-spoke protocol-suite by providing deep, focused testing of hub core functionality in isolation. diff --git a/invariants/hub-suite/Setup.t.sol b/invariants/hub-suite/Setup.t.sol new file mode 100644 index 000000000..8c45bf273 --- /dev/null +++ b/invariants/hub-suite/Setup.t.sol @@ -0,0 +1,361 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +// Libraries +import {ActorsUtils} from '../shared/utils/ActorsUtils.sol'; +import {Constants} from 'tests/Constants.sol'; +import {Roles} from 'src/libraries/types/Roles.sol'; +import {Actor} from '../shared/utils/Actor.sol'; + +// Test Contracts +import {TestnetERC20} from 'tests/mocks/TestnetERC20.sol'; + +// Contracts +import {BaseTest} from './base/BaseTest.t.sol'; +import {DeployUtils} from 'tests/DeployUtils.sol'; +import {AssetInterestRateStrategy} from 'src/hub/AssetInterestRateStrategy.sol'; +import {IAssetInterestRateStrategy} from 'src/hub/interfaces/IAssetInterestRateStrategy.sol'; +import {AccessManager} from 'src/dependencies/openzeppelin/AccessManager.sol'; +import {HubConfigurator, IHubConfigurator} from 'src/hub/HubConfigurator.sol'; +import {Hub, IHub} from 'src/hub/Hub.sol'; + +/// @notice Setup contract for the invariant test Suite, inherited by Tester +contract Setup is BaseTest { + /// @notice Number of actors to deploy + function _setUp() internal { + // Deploy the suite assets + _deployAssets(); + + // Deploy protocol contracts and protocol actors + _deployProtocolCore(); + + // Deploy actors + _setUpActors(); + + // Configure the token list on the protocol + _configureTokenList(); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // ASSETS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Deploy the suite assets + function _deployAssets() internal { + usdc = new TestnetERC20('USDC', 'USDC', 6); + weth = new TestnetERC20('WETH', 'WETH', 18); + wbtc = new TestnetERC20('WBTC', 'WBTC', 8); + + baseAssets.push(AssetInfo({underlying: address(usdc), decimals: 6})); + baseAssets.push(AssetInfo({underlying: address(weth), decimals: 18})); + baseAssets.push(AssetInfo({underlying: address(wbtc), decimals: 8})); + + vm.label(address(usdc), 'usdc'); + vm.label(address(weth), 'weth'); + vm.label(address(wbtc), 'wbtc'); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // CORE // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Deploy protocol core contracts + function _deployProtocolCore() internal { + // Access manager + accessManager = new AccessManager(admin); + + // Hub 1 + hub = new Hub(address(accessManager)); + irStrategy = new AssetInterestRateStrategy(address(hub)); + + // Configurators + hubConfigurator = new HubConfigurator(address(accessManager)); + _setUpConfiguratorRoles(); + + vm.label(address(accessManager), 'accessManager'); + vm.label(address(hub), 'hub'); + vm.label(address(hubConfigurator), 'hubConfigurator'); + vm.label(address(irStrategy), 'irStrategy'); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // CONFIGS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function _configureTokenList() internal { + // Configure hubs + _configureHubs(); + + // Configure spokes + _configureSpokes(); + } + + /// @notice Configure the hubs + function _configureHubs() internal { + // HUB 1 + bytes memory encodedIrData = abi.encode( + IAssetInterestRateStrategy.InterestRateData({ + optimalUsageRatio: OPTIMAL_USAGE_RATIO_IR1, + baseVariableBorrowRate: BASE_VARIABLE_BORROW_RATE_IR1, + variableRateSlope1: VARIABLE_RATE_SLOPE_1_IR1, + variableRateSlope2: VARIABLE_RATE_SLOPE_2_IR1 + }) + ); + + // Add USDC + usdcAssetId = hub.addAsset( + address(usdc), + usdc.decimals(), + address(this), + address(irStrategy), + encodedIrData + ); + hub.updateAssetConfig( + usdcAssetId, + IHub.AssetConfig({ + liquidityFee: 5_00, + feeReceiver: address(this), + irStrategy: address(irStrategy), + reinvestmentController: address(this) + }), + new bytes(0) + ); + hubAssetIds.push(usdcAssetId); + assetIdToUnderlying[usdcAssetId] = address(usdc); + underlyingToAssetId[address(usdc)] = usdcAssetId; + + // Add WETH + wethAssetId = hub.addAsset( + address(weth), + weth.decimals(), + address(this), + address(irStrategy), + encodedIrData + ); + hub.updateAssetConfig( + wethAssetId, + IHub.AssetConfig({ + liquidityFee: 10_00, + feeReceiver: address(this), + irStrategy: address(irStrategy), + reinvestmentController: address(this) + }), + new bytes(0) + ); + hubAssetIds.push(wethAssetId); + assetIdToUnderlying[wethAssetId] = address(weth); + underlyingToAssetId[address(weth)] = wethAssetId; + + // Add WBTC + wbtcAssetId = hub.addAsset( + address(wbtc), + wbtc.decimals(), + address(this), + address(irStrategy), + encodedIrData + ); + hub.updateAssetConfig( + wbtcAssetId, + IHub.AssetConfig({ + liquidityFee: 5_00, + feeReceiver: address(this), + irStrategy: address(irStrategy), + reinvestmentController: address(this) + }), + new bytes(0) + ); + hubAssetIds.push(wbtcAssetId); + assetIdToUnderlying[wbtcAssetId] = address(wbtc); + underlyingToAssetId[address(wbtc)] = wbtcAssetId; + } + + function _configureSpokes() internal { + // Spoke 1: usdc, weth and wbtc + // Spoke 2: weth and wbtc + // Spoke 3: usdc, weth and wbtc + // Only Spoke 3 is granted DEFICIT_ELIMINATOR Role + + // Add SPOKE 1 assets to hub + hub.addSpoke( + usdcAssetId, + address(userToActor[USER1]), + IHub.SpokeConfig({ + addCap: Constants.MAX_ALLOWED_SPOKE_CAP, + drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, + riskPremiumThreshold: Constants.MAX_RISK_PREMIUM_THRESHOLD, + active: true, + halted: false + }) + ); + hub.addSpoke( + wethAssetId, + address(userToActor[USER1]), + IHub.SpokeConfig({ + addCap: (Constants.MAX_ALLOWED_SPOKE_CAP / 10) * 3, + drawCap: (Constants.MAX_ALLOWED_SPOKE_CAP / 10) * 3, + riskPremiumThreshold: Constants.MAX_RISK_PREMIUM_THRESHOLD, + active: true, + halted: false + }) + ); + hub.addSpoke( + wbtcAssetId, + address(userToActor[USER1]), + IHub.SpokeConfig({ + addCap: Constants.MAX_ALLOWED_SPOKE_CAP, + drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, + riskPremiumThreshold: Constants.MAX_RISK_PREMIUM_THRESHOLD, + active: true, + halted: false + }) + ); + + // Add SPOKE 2 assets to hub + hub.addSpoke( + wethAssetId, + address(userToActor[USER2]), + IHub.SpokeConfig({ + addCap: (Constants.MAX_ALLOWED_SPOKE_CAP / 10) * 2, + drawCap: (Constants.MAX_ALLOWED_SPOKE_CAP / 10) * 2, + riskPremiumThreshold: Constants.MAX_RISK_PREMIUM_THRESHOLD, + active: true, + halted: false + }) + ); + hub.addSpoke( + wbtcAssetId, + address(userToActor[USER2]), + IHub.SpokeConfig({ + addCap: Constants.MAX_ALLOWED_SPOKE_CAP, + drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, + riskPremiumThreshold: Constants.MAX_RISK_PREMIUM_THRESHOLD, + active: true, + halted: false + }) + ); + + // Add SPOKE 3 assets to hub + hub.addSpoke( + usdcAssetId, + address(userToActor[USER3]), + IHub.SpokeConfig({ + addCap: Constants.MAX_ALLOWED_SPOKE_CAP, + drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, + riskPremiumThreshold: Constants.MAX_RISK_PREMIUM_THRESHOLD, + active: true, + halted: false + }) + ); + hub.addSpoke( + wethAssetId, + address(userToActor[USER3]), + IHub.SpokeConfig({ + addCap: (Constants.MAX_ALLOWED_SPOKE_CAP / 10) * 2, + drawCap: (Constants.MAX_ALLOWED_SPOKE_CAP / 10) * 2, + riskPremiumThreshold: Constants.MAX_RISK_PREMIUM_THRESHOLD, + active: true, + halted: false + }) + ); + hub.addSpoke( + wbtcAssetId, + address(userToActor[USER3]), + IHub.SpokeConfig({ + addCap: Constants.MAX_ALLOWED_SPOKE_CAP, + drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, + riskPremiumThreshold: Constants.MAX_RISK_PREMIUM_THRESHOLD, + active: true, + halted: false + }) + ); + + usdc.approve(address(hub), type(uint256).max); + weth.approve(address(hub), type(uint256).max); + wbtc.approve(address(hub), type(uint256).max); + + accessManager.grantRole(Roles.DEFICIT_ELIMINATOR_ROLE, address(userToActor[USER3]), 0); + { + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = IHub.eliminateDeficit.selector; + accessManager.setTargetFunctionRole(address(hub), selectors, Roles.DEFICIT_ELIMINATOR_ROLE); + } + } + + /// @notice Set up roles for the configurators + function _setUpConfiguratorRoles() internal virtual { + // Grant roles to configurators + accessManager.grantRole(Roles.HUB_ADMIN_ROLE, address(hubConfigurator), 0); + accessManager.grantRole(Roles.HUB_ADMIN_ROLE, address(this), 0); + accessManager.grantRole(Roles.HUB_CONFIGURATOR_ROLE, address(this), 0); + + // Grant responsibilities on hubs + { + bytes4[] memory selectors = new bytes4[](4); + selectors[0] = IHub.updateSpokeConfig.selector; + selectors[1] = IHub.setInterestRateData.selector; + selectors[2] = IHub.updateAssetConfig.selector; + selectors[3] = IHub.mintFeeShares.selector; + accessManager.setTargetFunctionRole(address(hub), selectors, Roles.HUB_ADMIN_ROLE); + } + + { + bytes4[] memory selectors = new bytes4[](22); + selectors[0] = IHubConfigurator.updateLiquidityFee.selector; + selectors[1] = IHubConfigurator.updateFeeReceiver.selector; + selectors[2] = IHubConfigurator.updateFeeConfig.selector; + selectors[3] = IHubConfigurator.updateInterestRateStrategy.selector; + selectors[4] = IHubConfigurator.updateReinvestmentController.selector; + selectors[5] = IHubConfigurator.resetAssetCaps.selector; + selectors[6] = IHubConfigurator.deactivateAsset.selector; + selectors[7] = IHubConfigurator.haltAsset.selector; + selectors[8] = IHubConfigurator.addSpoke.selector; + selectors[9] = IHubConfigurator.addSpokeToAssets.selector; + selectors[10] = IHubConfigurator.updateSpokeActive.selector; + selectors[11] = IHubConfigurator.updateSpokeHalted.selector; + selectors[12] = IHubConfigurator.updateSpokeAddCap.selector; + selectors[13] = IHubConfigurator.updateSpokeDrawCap.selector; + selectors[14] = IHubConfigurator.updateSpokeRiskPremiumThreshold.selector; + selectors[15] = IHubConfigurator.updateSpokeCaps.selector; + selectors[16] = IHubConfigurator.deactivateSpoke.selector; + selectors[17] = IHubConfigurator.haltSpoke.selector; + selectors[18] = IHubConfigurator.resetSpokeCaps.selector; + selectors[19] = IHubConfigurator.updateInterestRateData.selector; + selectors[20] = IHubConfigurator.addAsset.selector; + selectors[21] = IHubConfigurator.addAssetWithDecimals.selector; + + accessManager.setTargetFunctionRole( + address(hubConfigurator), + selectors, + Roles.HUB_CONFIGURATOR_ROLE + ); + } + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // ACTORS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Deploy protocol actors and initialize their balances + function _setUpActors() internal { + // Initialize the three actors of the fuzzers + address[] memory addresses = new address[](3); + addresses[0] = USER1; + addresses[1] = USER2; + addresses[2] = USER3; + + // Initialize the tokens array + address[] memory tokens = new address[](3); + tokens[0] = address(usdc); + tokens[1] = address(weth); + tokens[2] = address(wbtc); + + address[] memory contracts = new address[](1); + contracts[0] = address(hub); + + actors = ActorsUtils.setUpActors(addresses, tokens, contracts); + userToActor[USER1] = Actor(payable(actors[0])); + userToActor[USER2] = Actor(payable(actors[1])); + userToActor[USER3] = Actor(payable(actors[2])); + } +} diff --git a/invariants/hub-suite/SpecAggregator.t.sol b/invariants/hub-suite/SpecAggregator.t.sol new file mode 100644 index 000000000..721a3744e --- /dev/null +++ b/invariants/hub-suite/SpecAggregator.t.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +// Test Contracts +import {HubInvariantsSpec} from './specs/HubInvariantsSpec.t.sol'; +import {HubPostconditionsSpec} from './specs/HubPostconditionsSpec.t.sol'; + +/// @title SpecAggregator +/// @notice Helper contract to aggregate all spec contracts, inherited in BaseHooks +/// @dev inherits HubInvariantsSpec, HubPostconditionsSpec +abstract contract SpecAggregator is HubInvariantsSpec, HubPostconditionsSpec {} diff --git a/invariants/hub-suite/Tester.t.sol b/invariants/hub-suite/Tester.t.sol new file mode 100644 index 000000000..2a79bd796 --- /dev/null +++ b/invariants/hub-suite/Tester.t.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import {Invariants} from './Invariants.t.sol'; +import {Setup} from './Setup.t.sol'; + +/// @title Tester +/// @notice Entry point for hub invariant testing +contract Tester is Invariants, Setup { + constructor() payable { + setUp(); + } + + function setUp() internal { + _setUp(); + } +} diff --git a/invariants/hub-suite/_config/echidna_config.yaml b/invariants/hub-suite/_config/echidna_config.yaml new file mode 100644 index 000000000..8b5045125 --- /dev/null +++ b/invariants/hub-suite/_config/echidna_config.yaml @@ -0,0 +1,63 @@ +#codeSize max code size for deployed contratcs (default 24576, per EIP-170) +codeSize: 224576 + +#whether ot not to use the multi-abi mode of testing +#it’s not working for us, see: https://github.com/crytic/echidna/issues/547 +#multi-abi: true + +#balanceAddr is default balance for addresses +balanceAddr: 0x1000000000000000000000000 +#balanceContract overrides balanceAddr for the contract address (2^128 = ~3e38) +balanceContract: 0x1000000000000000000000000000000000000000000000000 + +#testLimit is the number of test sequences to run +testLimit: 20000000 + +#seqLen defines how many transactions are in a test sequence +seqLen: 300 + +#shrinkLimit determines how much effort is spent shrinking failing sequences +shrinkLimit: 2500 + +#propMaxGas defines gas cost at which a property fails +propMaxGas: 1000000000 + +#testMaxGas is a gas limit; does not cause failure, but terminates sequence +testMaxGas: 1000000000 + +# list of methods to filter +filterFunctions: ["Tester.checkPostConditions()"] +# by default, blacklist methods in filterFunctions +#filterBlacklist: false + +prefix: "invariant_" + +#stopOnFail makes echidna terminate as soon as any property fails and has been shrunk +stopOnFail: false + +#coverage controls coverage guided testing +coverage: true + +# list of file formats to save coverage reports in; default is all possible formats +coverageFormats: ["html"] + +#directory to save the corpus; by default is disabled +corpusDir: "invariants/hub-suite/_corpus/echidna/default/_data/corpus" +# constants for corpus mutations (for experimentation only) +#mutConsts: [100, 1, 1] + +#remappings +cryticArgs: ["--compile-libraries=(LiquidationLogic,0xf01)"] + +deployContracts: [["0xf01", "LiquidationLogic"]] + +# maximum value to send to payable functions +maxValue: 1e+30 # 100000000000 eth + +#quiet produces (much) less verbose output +quiet: false + +format: "text" + +# concurrent workers +workers: 10 diff --git a/invariants/hub-suite/_config/echidna_config_ci.yaml b/invariants/hub-suite/_config/echidna_config_ci.yaml new file mode 100644 index 000000000..35d23ee45 --- /dev/null +++ b/invariants/hub-suite/_config/echidna_config_ci.yaml @@ -0,0 +1,64 @@ +#codeSize max code size for deployed contratcs (default 24576, per EIP-170) +codeSize: 224576 + +#whether ot not to use the multi-abi mode of testing +#it’s not working for us, see: https://github.com/crytic/echidna/issues/547 +#multi-abi: true + +#balanceAddr is default balance for addresses +balanceAddr: 0x1000000000000000000000000 +#balanceContract overrides balanceAddr for the contract address (2^128 = ~3e38) +balanceContract: 0x1000000000000000000000000000000000000000000000000 + +#testLimit is the number of test sequences to run +testLimit: 10000000 + +#timeout in seconds +timeout: 3600 # 1 hour + +#seqLen defines how many transactions are in a test sequence +seqLen: 300 + +#shrinkLimit determines how much effort is spent shrinking failing sequences +shrinkLimit: 1500 + +#propMaxGas defines gas cost at which a property fails +propMaxGas: 1000000000 + +#testMaxGas is a gas limit; does not cause failure, but terminates sequence +testMaxGas: 1000000000 + +# list of methods to filter +filterFunctions: ["Tester.checkPostConditions()"] +# by default, blacklist methods in filterFunctions +#filterBlacklist: false + +prefix: "invariant_" + +#stopOnFail makes echidna terminate as soon as any property fails and has been shrunk +stopOnFail: false + +#coverage controls coverage guided testing +coverage: true + +# list of file formats to save coverage reports in; default is all possible formats +coverageFormats: ["html"] + +#directory to save the corpus; by default is disabled +corpusDir: "corpus" +# constants for corpus mutations (for experimentation only) +#mutConsts: [100, 1, 1] + +#remappings +cryticArgs: ["--ignore-compile", "--compile-libraries=(LiquidationLogic,0xf01)"] + +deployContracts: [["0xf01", "LiquidationLogic"]] + +# maximum value to send to payable functions +maxValue: 1e+30 # 100000000000 eth + +#quiet produces (much) less verbose output +quiet: false + +# concurrent workers +workers: 10 diff --git a/invariants/hub-suite/_config/medusa_config_ci.json b/invariants/hub-suite/_config/medusa_config_ci.json new file mode 100644 index 000000000..cac15ff5a --- /dev/null +++ b/invariants/hub-suite/_config/medusa_config_ci.json @@ -0,0 +1,80 @@ +{ + "fuzzing": { + "workers": 10, + "workerResetLimit": 50, + "timeout": 3600, + "testLimit": 0, + "callSequenceLength": 300, + "corpusDirectory": "corpus", + "coverageEnabled": true, + "deploymentOrder": ["Tester"], + "targetContracts": ["Tester"], + "targetContractsBalances": [ + "0xffffffffffffffffffffffffffffffffffffffffffffffffffff" + ], + "predeployedContracts": {}, + "constructorArgs": {}, + "deployerAddress": "0x30000", + "senderAddresses": ["0x10000", "0x20000", "0x30000"], + "blockNumberDelayMax": 60480, + "blockTimestampDelayMax": 604800, + "blockGasLimit": 12500000000, + "transactionGasLimit": 1250000000, + "testing": { + "stopOnFailedTest": true, + "stopOnFailedContractMatching": false, + "stopOnNoTests": true, + "testAllContracts": false, + "traceAll": false, + "assertionTesting": { + "enabled": true, + "testViewMethods": true, + "assertionModes": { + "failOnCompilerInsertedPanic": false, + "failOnAssertion": true, + "failOnArithmeticUnderflow": false, + "failOnDivideByZero": false, + "failOnEnumTypeConversionOutOfBounds": false, + "failOnIncorrectStorageAccess": false, + "failOnPopEmptyArray": false, + "failOnOutOfBoundsArrayAccess": false, + "failOnAllocateTooMuchMemory": false, + "failOnCallUninitializedVariable": false + } + }, + "propertyTesting": { + "enabled": true, + "testPrefixes": ["fuzz_", "invariant_"] + }, + "optimizationTesting": { + "enabled": false, + "testPrefixes": ["optimize_"] + }, + "excludeFunctionSignatures": ["Tester.checkPostConditions()"] + }, + "chainConfig": { + "codeSizeCheckDisabled": true, + "cheatCodes": { + "cheatCodesEnabled": true, + "enableFFI": false + } + } + }, + "compilation": { + "platform": "crytic-compile", + "platformConfig": { + "target": "invariants/hub-suite/Tester.t.sol", + "solcVersion": "", + "exportDirectory": "", + "args": [ + "--ignore-compile", + "--solc-remaps", + "forge-std/=../../../lib/forge-std/src/" + ] + } + }, + "logging": { + "level": "info", + "logDirectory": "" + } +} diff --git a/invariants/hub-suite/base/BaseHandler.t.sol b/invariants/hub-suite/base/BaseHandler.t.sol new file mode 100644 index 000000000..c8c5dad60 --- /dev/null +++ b/invariants/hub-suite/base/BaseHandler.t.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import {CommonHelpers} from '../../shared/utils/CommonHelpers.sol'; +import {HookAggregator} from '../hooks/HookAggregator.t.sol'; + +/// @title BaseHandler +/// @notice Contains common logic for all handlers +/// @dev inherits all suite assertions since per action assertions are implmenteds in the handlers +contract BaseHandler is HookAggregator, CommonHelpers { + /////////////////////////////////////////////////////////////////////////////////////////////// + // MODIFIERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /////////////////////////////////////////////////////////////////////////////////////////////// + // SHARED VARIABLES // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// +} diff --git a/invariants/hub-suite/base/BaseHooks.t.sol b/invariants/hub-suite/base/BaseHooks.t.sol new file mode 100644 index 000000000..af6c30598 --- /dev/null +++ b/invariants/hub-suite/base/BaseHooks.t.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +// Contracts +import {ProtocolAssertions} from './ProtocolAssertions.t.sol'; + +// Test Contracts +import {SpecAggregator} from '../SpecAggregator.t.sol'; + +/// @title BaseHooks +/// @notice Contains common logic for all hooks +/// @dev inherits all suite assertions since per-action assertions are implemented in the handlers +/// @dev inherits SpecAggregator +contract BaseHooks is ProtocolAssertions, SpecAggregator { + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// +} diff --git a/invariants/hub-suite/base/BaseStorage.t.sol b/invariants/hub-suite/base/BaseStorage.t.sol new file mode 100644 index 000000000..7aacb734c --- /dev/null +++ b/invariants/hub-suite/base/BaseStorage.t.sol @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +// Contracts +import {TestnetERC20} from 'tests/mocks/TestnetERC20.sol'; +import {AssetInterestRateStrategy} from 'src/hub/AssetInterestRateStrategy.sol'; +import {AccessManager} from 'src/dependencies/openzeppelin/AccessManager.sol'; +import {HubConfigurator} from 'src/hub/HubConfigurator.sol'; +import {IAaveOracle} from 'src/spoke/interfaces/IAaveOracle.sol'; +import {IHub} from 'src/hub/interfaces/IHub.sol'; + +// Utils +import {Actor} from '../../shared/utils/Actor.sol'; + +/// @notice BaseStorage contract for hub-focused test suite +abstract contract BaseStorage { + /////////////////////////////////////////////////////////////////////////////////////////////// + // CONSTANTS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + uint256 constant MAX_TOKEN_AMOUNT = 1e29; + + uint256 constant ONE_DAY = 1 days; + uint256 constant ONE_MONTH = ONE_YEAR / 12; + uint256 constant ONE_YEAR = 365 days; + + uint256 internal constant NUMBER_OF_ACTORS = 3; + uint256 internal constant INITIAL_ETH_BALANCE = 1e26; + uint256 internal constant INITIAL_COLL_BALANCE = 1e21; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // ACTORS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice The address of the admin, the tester itself + address internal admin = address(this); + + /// @notice Stores the actor during a handler call + Actor internal actor; + + /// @notice Mapping of fuzzer user addresses to actors + mapping(address => Actor) internal userToActor; + + /// @notice Array of all actor addresses (simulating spokes) + address[] internal actors; + + /// @notice The signature of the action that is being executed + bytes4 internal currentActionSignature; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // ASSETS STORAGE // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice The USDC token + TestnetERC20 internal usdc; + /// @notice The WETH token + TestnetERC20 internal weth; + /// @notice The WBTC token + TestnetERC20 internal wbtc; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HUB STORAGE // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Single Hub instance + IHub internal hub; + + /// @notice Interest rate strategy for the hub + AssetInterestRateStrategy internal irStrategy; + + /// @notice Hub Configurator + HubConfigurator internal hubConfigurator; + + /// @notice Access Manager + AccessManager internal accessManager; + + // PRICE FEEDS + address[] internal priceFeeds; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // ASSET CONFIG // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Asset info struct + struct AssetInfo { + address underlying; + uint8 decimals; + } + + /// @notice Array of base assets for the hub + AssetInfo[] internal baseAssets; + + /// @notice Hub asset IDs + uint256 internal wethAssetId; + uint256 internal usdcAssetId; + uint256 internal wbtcAssetId; + uint256[] internal hubAssetIds; + mapping(uint256 => address) internal assetIdToUnderlying; + mapping(address => uint256) internal underlyingToAssetId; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // SPOKE CONFIG // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Array of spoke addresses (actors acting as spokes) + address[] internal spokeAddresses; +} diff --git a/invariants/hub-suite/base/BaseTest.t.sol b/invariants/hub-suite/base/BaseTest.t.sol new file mode 100644 index 000000000..46850898c --- /dev/null +++ b/invariants/hub-suite/base/BaseTest.t.sol @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +// Libraries +import {Vm} from 'forge-std/Base.sol'; +import {StdUtils} from 'forge-std/StdUtils.sol'; + +// Interfaces +import {IERC20} from 'src/dependencies/openzeppelin/IERC20.sol'; +import {IHub} from 'src/hub/interfaces/IHub.sol'; +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; + +// Utils +import {Actor} from '../../shared/utils/Actor.sol'; +import {PropertiesConstants} from '../../shared/utils/PropertiesConstants.sol'; +import {StdAsserts} from '../../shared/utils/StdAsserts.sol'; + +// Base +import {BaseStorage} from './BaseStorage.t.sol'; + +/// @notice Base contract for all test contracts extends BaseStorage +/// @dev Provides setup modifier and cheat code setup +/// @dev inherits Storage, Testing constants assertions and utils needed for testing +abstract contract BaseTest is BaseStorage, PropertiesConstants, StdAsserts, StdUtils { + bool internal IS_TEST = true; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // ACTOR PROXY MECHANISM // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @dev Actor proxy mechanism + modifier setup() virtual { + actor = userToActor[msg.sender]; + _; + delete actor; + } + + /// @dev Solves medusa backward time warp issue + modifier monotonicTimestamp() virtual { + // @dev Implement monotonic timestamp if needed + _; + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // CALLBACKS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + receive() external payable {} + + /////////////////////////////////////////////////////////////////////////////////////////////// + // CHEAT CODE SETUP // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @dev Cheat code address, 0x7109709ECfa91a80626fF3989D68f67F5b1DD12D. + address internal constant VM_ADDRESS = address(uint160(uint256(keccak256('hevm cheat code')))); + + /// @dev Virtual machine instance + Vm internal constant vm = Vm(VM_ADDRESS); + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS: RANDOM GETTERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Get a random actor proxy address + function _getRandomActor(uint256 _i) internal view returns (address) { + uint256 _actorIndex = _i % NUMBER_OF_ACTORS; + return actors[_actorIndex]; + } + + /// @notice Helper function to get a random base asset + function _getRandomBaseAsset(uint256 i) internal view returns (address) { + uint256 _assetIndex = i % baseAssets.length; + return baseAssets[_assetIndex].underlying; + } + + /// @notice Helper function to get random base asset full info + function _getRandomBaseAssetId(uint256 i) internal view returns (uint256) { + uint256 _assetIndex = i % hubAssetIds.length; + return hubAssetIds[_assetIndex]; + } + + /// @notice Helper function to get random base asset full info + function _getRandomBaseAssetFullInfo(uint256 i) internal view returns (AssetInfo memory) { + uint256 _assetIndex = i % baseAssets.length; + return baseAssets[_assetIndex]; + } + + /// @notice Helper function to get a random price feed address + function _getRandomPriceFeed(uint256 i) internal view returns (address) { + uint256 _priceFeedIndex = i % priceFeeds.length; + return priceFeeds[_priceFeedIndex]; + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS: GETTERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Get a random address + function _makeAddr(string memory name) internal pure returns (address addr) { + uint256 privateKey = uint256(keccak256(abi.encodePacked(name))); + addr = vm.addr(privateKey); + } + + /// @notice Helper function to transfer tokens by actor + function _transferByActor(address token, address to, uint256 amount) internal { + (bool ok, bytes memory ret) = actor.proxy(token, abi.encodeCall(IERC20.transfer, (to, amount))); + require(ok, string(ret)); + } + + /// @notice Helper function to approve tokens by actor + function _approveByActor(address token, address spender, uint256 amount) internal { + (bool ok, bytes memory ret) = actor.proxy( + token, + abi.encodeCall(IERC20.approve, (spender, amount)) + ); + require(ok, string(ret)); + } + + /// @notice Helper function to calculate burnt interest in assets terms (originating from virtual shares) + function _calculateBurntInterest(IHub hub_, uint256 assetId_) internal view returns (uint256) { + uint256 totalAssets = hub_.getAddedAssets(assetId_); + uint256 totalShares = hub_.getAddedShares(assetId_); + return totalAssets - hub_.previewRemoveByShares(assetId_, totalShares); + } +} diff --git a/invariants/hub-suite/base/ProtocolAssertions.t.sol b/invariants/hub-suite/base/ProtocolAssertions.t.sol new file mode 100644 index 000000000..0b6b9bd1a --- /dev/null +++ b/invariants/hub-suite/base/ProtocolAssertions.t.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +// Base +import {BaseTest} from './BaseTest.t.sol'; +import {StdAsserts} from '../../shared/utils/StdAsserts.sol'; + +/// @title ProtocolAssertions +/// @notice Helper contract for protocol specific assertions +contract ProtocolAssertions is StdAsserts, BaseTest {} diff --git a/invariants/hub-suite/handlers/HubAdminHandler.t.sol b/invariants/hub-suite/handlers/HubAdminHandler.t.sol new file mode 100644 index 000000000..680d7668e --- /dev/null +++ b/invariants/hub-suite/handlers/HubAdminHandler.t.sol @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +// Interfaces +import {IHub} from 'src/hub/interfaces/IHub.sol'; +import {IHubAdminHandler} from './interfaces/IHubAdminHandler.sol'; + +// Test Contracts +import {CommonHelpers} from '../../shared/utils/CommonHelpers.sol'; +import {BaseHandler} from '../base/BaseHandler.t.sol'; + +/// @title HubAdminHandlerBase +/// @notice Handler for hub-level admin operations that are not configurator-restricted: +/// liquidity reinvestment (sweep/reclaim) and fee share minting (mintFeeShares). +/// @dev These target the Hub's reinvestment controller interface and access-managed fee paths. +/// The handler itself is the caller (not an actor proxy), so it must be configured as the +/// reinvestmentController for sweep/reclaim and granted permissions for mintFeeShares. +abstract contract HubAdminHandlerBase is CommonHelpers, IHubAdminHandler { + /////////////////////////////////////////////////////////////////////////////////////////////// + // ACTIONS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function sweep(uint256 amount, uint8 i, uint8 j) external { + uint256 assetId = _getRandomAssetId(i); + IHub hub = _getRandomHub(j); + + _beforeHook(); + try hub.sweep(assetId, amount) { + _afterHook(); + } catch { + revert('HubHandler: sweep failed'); + } + } + + function reclaim(uint256 amount, uint8 i, uint8 j) external { + uint256 assetId = _getRandomAssetId(i); + IHub hub = _getRandomHub(j); + + _tryMint(hub.getAsset(assetId).underlying, address(hub), amount); + + _beforeHook(); + try hub.reclaim(assetId, amount) { + _afterHook(); + } catch { + revert('HubHandler: reclaim failed'); + } + } + + function mintFeeShares(uint8 i, uint8 j) external { + uint256 assetId = _getRandomAssetId(i); + IHub hub = _getRandomHub(j); + + _beforeHook(); + try hub.mintFeeShares(assetId) { + _afterHook(); + } catch { + revert('HubHandler: mintFeeShares failed'); + } + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @dev Hook invoked before each handler action. Concrete handlers wire this to the suite's _before() snapshot. + function _beforeHook() internal virtual; + + /// @dev Hook invoked after each handler action. Concrete handlers wire this to the suite's _after() snapshot + postcondition checks. + function _afterHook() internal virtual; + + /// @dev Returns the hub to target for the current action. + function _getRandomHub(uint8 i) internal view virtual returns (IHub); + + /// @dev Returns the hub-level assetId to target for the current action. + function _getRandomAssetId(uint8 i) internal view virtual returns (uint256); +} + +/// @title HubAdminHandler +/// @notice Hub-suite concrete handler — single hub, selects a random asset id per call. +contract HubAdminHandler is HubAdminHandlerBase, BaseHandler { + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function _beforeHook() internal override { + _before(); + } + + function _afterHook() internal override { + _after(); + } + + function _getRandomHub(uint8) internal view override returns (IHub) { + return hub; + } + + function _getRandomAssetId(uint8 i) internal view override returns (uint256) { + return _getRandomBaseAssetId(i); + } +} diff --git a/invariants/hub-suite/handlers/HubConfiguratorHandler.t.sol b/invariants/hub-suite/handlers/HubConfiguratorHandler.t.sol new file mode 100644 index 000000000..a002755f9 --- /dev/null +++ b/invariants/hub-suite/handlers/HubConfiguratorHandler.t.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +// Libraries +import {PercentageMath} from 'src/libraries/math/PercentageMath.sol'; + +// Interfaces +import {IERC20} from 'src/dependencies/openzeppelin/IERC20.sol'; +import {IHubConfiguratorHandler} from './interfaces/IHubConfiguratorHandler.sol'; + +// Test Contracts +import {Actor} from '../../shared/utils/Actor.sol'; +import {BaseHandler} from '../base/BaseHandler.t.sol'; + +/// @title HubConfiguratorHandler +/// @notice Handler test contract for a set of actions +/// @dev Inputs are bounded to Hub validation constraints so admin actions don't unnecessarily +/// discard fuzzer runs. +contract HubConfiguratorHandler is BaseHandler, IHubConfiguratorHandler { + /////////////////////////////////////////////////////////////////////////////////////////////// + // STATE VARIABLES // + /////////////////////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////////////////////// + // ACTIONS // + /////////////////////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////////////////////// + // SPOKE CONFIG // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function updateSpokeAddCap(uint256 addCap, uint8 i, uint8 j) external setup { + uint256 assetId = _getRandomBaseAssetId(i); + address spoke = _getRandomActor(j); + addCap = _bound(addCap, 0, MAX_ALLOWED_SPOKE_CAP); + hubConfigurator.updateSpokeAddCap(address(hub), assetId, spoke, addCap); + } + + function updateSpokeDrawCap(uint256 drawCap, uint8 i, uint8 j) external setup { + uint256 assetId = _getRandomBaseAssetId(i); + address spoke = _getRandomActor(j); + drawCap = _bound(drawCap, 0, MAX_ALLOWED_SPOKE_CAP); + hubConfigurator.updateSpokeDrawCap(address(hub), assetId, spoke, drawCap); + } + + function updateSpokeRiskPremiumThreshold( + uint256 riskPremiumThreshold, + uint8 i, + uint8 j + ) external setup { + uint256 assetId = _getRandomBaseAssetId(i); + address spoke = _getRandomActor(j); + riskPremiumThreshold = _bound(riskPremiumThreshold, 0, MAX_RISK_PREMIUM_THRESHOLD); + hubConfigurator.updateSpokeRiskPremiumThreshold( + address(hub), + assetId, + spoke, + riskPremiumThreshold + ); + } + + function updateSpokeHalted(bool halted, uint8 i, uint8 j) external setup { + uint256 assetId = _getRandomBaseAssetId(i); + address spoke = _getRandomActor(j); + hubConfigurator.updateSpokeHalted(address(hub), assetId, spoke, halted); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // ASSET CONFIG // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function updateLiquidityFee(uint256 liquidityFee, uint8 i) external setup { + uint256 assetId = _getRandomBaseAssetId(i); + liquidityFee = _bound(liquidityFee, 0, PercentageMath.PERCENTAGE_FACTOR); + hubConfigurator.updateLiquidityFee(address(hub), assetId, liquidityFee); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// +} diff --git a/invariants/hub-suite/handlers/HubHandler.t.sol b/invariants/hub-suite/handlers/HubHandler.t.sol new file mode 100644 index 000000000..1d378d1c9 --- /dev/null +++ b/invariants/hub-suite/handlers/HubHandler.t.sol @@ -0,0 +1,453 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +// Interfaces +import {IHub, IHubBase} from 'src/hub/interfaces/IHub.sol'; +import {IHubHandler} from './interfaces/IHubHandler.sol'; + +// Libraries +import {WadRayMath} from 'src/libraries/math/WadRayMath.sol'; + +// Test Contracts +import {Actor} from '../../shared/utils/Actor.sol'; +import {BaseHandler} from '../base/BaseHandler.t.sol'; + +/// @title HubHandler +/// @notice Handler for hub-level operations through actor-spokes +contract HubHandler is BaseHandler, IHubHandler { + /////////////////////////////////////////////////////////////////////////////////////////////// + // ACTIONS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function add(uint256 amount, uint8 i) public setup returns (uint256 addedShares) { + uint256 assetId = _getRandomBaseAssetId(i); + address underlying = assetIdToUnderlying[assetId]; + + uint256 previewAddedShares = hub.previewAddByAssets(assetId, amount); + + uint256 assetsBefore = hub.getSpokeAddedAssets(assetId, address(actor)); + uint256 sharesBefore = hub.getSpokeAddedShares(assetId, address(actor)); + + _mint(underlying, address(hub), amount); + + _before(); + (bool ok, bytes memory ret) = actor.proxy( + address(hub), + abi.encodeCall(IHubBase.add, (assetId, amount)) + ); + + if (ok) { + _after(); + + addedShares = abi.decode(ret, (uint256)); + + assertGe( + assetsBefore + amount, + hub.getSpokeAddedAssets(assetId, address(actor)), + HSPOST_HUB_ERC4626_ADD_A + ); + assertEq( + sharesBefore + addedShares, + hub.getSpokeAddedShares(assetId, address(actor)), + HSPOST_HUB_ERC4626_ADD_B + ); + + assertLe(previewAddedShares, addedShares, HSPOST_HUB_ERC4626_ADD_C); + } else { + vm.assume(false); + } + } + + function remove(uint256 amount, uint8 i) public setup returns (uint256 removedShares) { + uint256 assetId = _getRandomBaseAssetId(i); + + uint256 previewRemovedShares = hub.previewRemoveByAssets(assetId, amount); + + uint256 assetsBefore = hub.getSpokeAddedAssets(assetId, address(actor)); + uint256 sharesBefore = hub.getSpokeAddedShares(assetId, address(actor)); + + _before(); + (bool ok, bytes memory ret) = actor.proxy( + address(hub), + abi.encodeCall(IHubBase.remove, (assetId, amount, address(actor))) + ); + + if (ok) { + _after(); + + removedShares = uint256(abi.decode(ret, (uint256))); + + assertGe( + assetsBefore, + hub.getSpokeAddedAssets(assetId, address(actor)) + amount, + HSPOST_HUB_ERC4626_REMOVE_A + ); + assertEq( + sharesBefore, + hub.getSpokeAddedShares(assetId, address(actor)) + removedShares, + HSPOST_HUB_ERC4626_REMOVE_B + ); + + assertGe(previewRemovedShares, removedShares, HSPOST_HUB_ERC4626_REMOVE_C); + } else { + vm.assume(false); + } + } + + function draw(uint256 amount, uint8 i) public setup returns (uint256 drawnShares) { + uint256 assetId = _getRandomBaseAssetId(i); + + uint256 previewDrawnShares = hub.previewDrawByAssets(assetId, amount); + + (uint256 drawnBefore, ) = hub.getSpokeOwed(assetId, address(actor)); + uint256 sharesBefore = hub.getSpokeDrawnShares(assetId, address(actor)); + + _before(); + (bool ok, bytes memory ret) = actor.proxy( + address(hub), + abi.encodeCall(IHubBase.draw, (assetId, amount, address(actor))) + ); + + if (ok) { + _after(); + + drawnShares = uint256(abi.decode(ret, (uint256))); + + (uint256 drawnAfter, ) = hub.getSpokeOwed(assetId, address(actor)); + + assertLe(drawnBefore + amount, drawnAfter, HSPOST_HUB_ERC4626_DRAW_A); + assertEq( + sharesBefore + drawnShares, + hub.getSpokeDrawnShares(assetId, address(actor)), + HSPOST_HUB_ERC4626_DRAW_B + ); + + assertGe(previewDrawnShares, drawnShares, HSPOST_HUB_ERC4626_DRAW_C); + } else { + vm.assume(false); + } + } + + function restore( + uint256 drawnAmount, + uint256 premiumAmount, + int256 sharesDelta, + uint8 i + ) public setup returns (uint256 restoredDrawnShares) { + uint256 assetId = _getRandomBaseAssetId(i); + + uint256 previewRestoredShares = hub.previewRestoreByAssets(assetId, drawnAmount); + + (uint256 drawnBefore, ) = hub.getSpokeOwed(assetId, address(actor)); + uint256 drawnSharesBefore = hub.getSpokeDrawnShares(assetId, address(actor)); + + IHubBase.PremiumDelta memory premiumDelta = _calculatePremiumDelta( + sharesDelta, + premiumAmount, + assetId + ); + + _mint(assetIdToUnderlying[assetId], address(hub), drawnAmount + premiumAmount); + + _before(); + (bool ok, bytes memory ret) = actor.proxy( + address(hub), + abi.encodeCall(IHubBase.restore, (assetId, drawnAmount, premiumDelta)) + ); + + if (ok) { + _after(); + + restoredDrawnShares = uint256(abi.decode(ret, (uint256))); + + (uint256 drawnAfter, ) = hub.getSpokeOwed(assetId, address(actor)); + + if (restoredDrawnShares > 0) { + uint256 tolerance = hub.previewRestoreByShares(assetId, 1); + assertApproxEqAbs( + drawnBefore, + drawnAfter + drawnAmount, + tolerance, + HSPOST_HUB_ERC4626_RESTORE_A + ); + } else { + // dust case, all restored assets donated + assertEq(drawnAfter, drawnBefore, HSPOST_HUB_ERC4626_RESTORE_A); + } + assertEq( + drawnSharesBefore, + hub.getSpokeDrawnShares(assetId, address(actor)) + restoredDrawnShares, + HSPOST_HUB_ERC4626_RESTORE_B + ); + + assertLe(previewRestoredShares, restoredDrawnShares, HSPOST_HUB_ERC4626_RESTORE_C); + } else { + vm.assume(false); + } + } + + function reportDeficit( + uint256 drawnAmount, + uint256 premiumAmount, + int256 sharesDelta, + uint8 i + ) external setup { + uint256 assetId = _getRandomBaseAssetId(i); + + IHubBase.PremiumDelta memory premiumDelta = _calculatePremiumDelta( + sharesDelta, + premiumAmount, + assetId + ); + + _before(); + (bool ok, ) = actor.proxy( + address(hub), + abi.encodeCall(IHubBase.reportDeficit, (assetId, drawnAmount, premiumDelta)) + ); + + if (ok) { + _after(); + } else { + vm.assume(false); + } + } + + function eliminateDeficit(uint256 amount, uint8 i) external setup { + uint256 assetId = _getRandomBaseAssetId(i); + // only spoke3 is given deficit eliminator role + address spoke = address(userToActor[USER3]); + + _before(); + (bool ok, ) = actor.proxy( + address(hub), + abi.encodeCall(IHub.eliminateDeficit, (assetId, amount, spoke)) + ); + + if (ok) { + _after(); + } else { + vm.assume(false); + } + } + + function refreshPremium(int256 sharesDelta, uint8 i) external setup { + uint256 assetId = _getRandomBaseAssetId(i); + + int256 offsetRayDelta = sharesDelta * int256(hub.getAssetDrawnIndex(assetId)); + IHubBase.PremiumDelta memory premiumDelta = IHubBase.PremiumDelta({ + sharesDelta: sharesDelta, + offsetRayDelta: offsetRayDelta, + restoredPremiumRay: 0 + }); + + _before(); + (bool ok, ) = actor.proxy( + address(hub), + abi.encodeCall(IHubBase.refreshPremium, (assetId, premiumDelta)) + ); + + if (ok) { + _after(); + + // HSPOST_HUB_M: refreshPremium cannot change total premium debt (only redistribution) + assertEq( + _assetVarsAfter(assetId).debt.premium, + _assetVarsBefore(assetId).debt.premium, + HSPOST_HUB_M + ); + } else { + vm.assume(false); + } + } + + // @dev broader `refreshPremium` to cover edge cases, above case exists for narrow happy path + function refreshPremiumBroad(IHubBase.PremiumDelta memory premiumDelta, uint8 i) external setup { + uint256 assetId = _getRandomBaseAssetId(i); + + _before(); + (bool ok, ) = actor.proxy( + address(hub), + abi.encodeCall(IHubBase.refreshPremium, (assetId, premiumDelta)) + ); + + if (ok) { + _after(); + + // HSPOST_HUB_M: refreshPremium cannot change total premium debt (only redistribution) + assertEq( + _assetVarsAfter(assetId).debt.premium, + _assetVarsBefore(assetId).debt.premium, + HSPOST_HUB_M + ); + } else { + vm.assume(false); + } + } + + function payFeeShares(uint256 shares, uint8 i) external setup { + uint256 assetId = _getRandomBaseAssetId(i); + + _before(); + (bool ok, ) = actor.proxy( + address(hub), + abi.encodeCall(IHubBase.payFeeShares, (assetId, shares)) + ); + if (ok) { + _after(); + } else { + vm.assume(false); + } + } + + function transferShares(uint256 shares, uint8 i, uint8 j) external setup { + uint256 assetId = _getRandomBaseAssetId(i); + address toSpoke = _getRandomActor(j); + + _before(); + (bool ok, ) = actor.proxy( + address(hub), + abi.encodeCall(IHub.transferShares, (assetId, shares, toSpoke)) + ); + + if (ok) { + _after(); + } else { + vm.assume(false); + } + } + + function sweep(uint256 amount, uint8 i) external { + uint256 assetId = _getRandomBaseAssetId(i); + + _before(); + // handler is the reinvestmentController + try hub.sweep(assetId, amount) { + _after(); + } catch { + vm.assume(false); + } + } + + function reclaim(uint256 amount, uint8 i) external { + uint256 assetId = _getRandomBaseAssetId(i); + address underlying = assetIdToUnderlying[assetId]; + + _mint(underlying, address(hub), amount); + + _before(); + // handler is the reinvestmentController + try hub.reclaim(assetId, amount) { + _after(); + } catch { + vm.assume(false); + } + } + + function mintFeeShares(uint8 i) external { + uint256 assetId = _getRandomBaseAssetId(i); + _before(); + try hub.mintFeeShares(assetId) { + _after(); + } catch { + vm.assume(false); + } + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // ERC4626 ROUNDTRIP (STATELESS) // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @dev Stateless roundtrip checks — pure preview calls, no state changes. + + /// @dev A: previewRemoveByShares(previewAddByAssets(a)) <= a + function roundtrip_ERC4626_RT_A(uint256 amount, uint8 i) external { + uint256 assetId = _getRandomBaseAssetId(i); + uint256 shares = hub.previewAddByAssets(assetId, amount); + uint256 assets = hub.previewRemoveByShares(assetId, shares); + assertLe(assets, amount, INV_HUB_ERC4626_RT_A); + } + + /// @dev B: previewRemoveByAssets(a) >= previewAddByAssets(a) + function roundtrip_ERC4626_RT_B(uint256 amount, uint8 i) external { + uint256 assetId = _getRandomBaseAssetId(i); + uint256 sDeposit = hub.previewAddByAssets(assetId, amount); + uint256 sWithdraw = hub.previewRemoveByAssets(assetId, amount); + assertGe(sWithdraw, sDeposit, INV_HUB_ERC4626_RT_B); + } + + /// @dev C: previewAddByAssets(previewRemoveByShares(s)) <= s + function roundtrip_ERC4626_RT_C(uint256 shares, uint8 i) external { + uint256 assetId = _getRandomBaseAssetId(i); + uint256 assets = hub.previewRemoveByShares(assetId, shares); + uint256 resultShares = hub.previewAddByAssets(assetId, assets); + assertLe(resultShares, shares, INV_HUB_ERC4626_RT_C); + } + + /// @dev D: previewAddByShares(s) >= previewRemoveByShares(s) + function roundtrip_ERC4626_RT_D(uint256 shares, uint8 i) external { + uint256 assetId = _getRandomBaseAssetId(i); + uint256 aRedeem = hub.previewRemoveByShares(assetId, shares); + uint256 aMint = hub.previewAddByShares(assetId, shares); + assertGe(aMint, aRedeem, INV_HUB_ERC4626_RT_D); + } + + /// @dev E: previewRemoveByAssets(previewAddByShares(s)) >= s + function roundtrip_ERC4626_RT_E(uint256 shares, uint8 i) external { + uint256 assetId = _getRandomBaseAssetId(i); + uint256 assets = hub.previewAddByShares(assetId, shares); + uint256 resultShares = hub.previewRemoveByAssets(assetId, assets); + assertGe(resultShares, shares, INV_HUB_ERC4626_RT_E); + } + + /// @dev F: previewRemoveByShares(s) <= previewAddByShares(s) + function roundtrip_ERC4626_RT_F(uint256 shares, uint8 i) external { + uint256 assetId = _getRandomBaseAssetId(i); + uint256 aMint = hub.previewAddByShares(assetId, shares); + uint256 aRedeem = hub.previewRemoveByShares(assetId, shares); + assertLe(aRedeem, aMint, INV_HUB_ERC4626_RT_F); + } + + /// @dev G: previewAddByShares(previewRemoveByAssets(a)) >= a + function roundtrip_ERC4626_RT_G(uint256 amount, uint8 i) external { + uint256 assetId = _getRandomBaseAssetId(i); + uint256 shares = hub.previewRemoveByAssets(assetId, amount); + uint256 assets = hub.previewAddByShares(assetId, shares); + assertGe(assets, amount, INV_HUB_ERC4626_RT_G); + } + + /// @dev H: previewAddByAssets(a) <= previewRemoveByAssets(a) + function roundtrip_ERC4626_RT_H(uint256 amount, uint8 i) external { + uint256 assetId = _getRandomBaseAssetId(i); + uint256 sWithdraw = hub.previewRemoveByAssets(assetId, amount); + uint256 sDeposit = hub.previewAddByAssets(assetId, amount); + assertLe(sDeposit, sWithdraw, INV_HUB_ERC4626_RT_H); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function _calculatePremiumDelta( + int256 sharesDelta, + uint256 premiumAmount, + uint256 assetId + ) internal view returns (IHubBase.PremiumDelta memory) { + uint256 drawnIndex = hub.getAssetDrawnIndex(assetId); + + // Calculate restoredPremiumRay from premiumAmount + uint256 restoredPremiumRay = premiumAmount * WadRayMath.RAY; + + // Calculate offsetRayDelta to satisfy: (sharesDelta * drawnIndex) - offsetRayDelta + restoredPremiumRay == 0 + // Therefore: offsetRayDelta = (sharesDelta * drawnIndex) + restoredPremiumRay + int256 offsetRayDelta = (sharesDelta * int256(drawnIndex)) + int256(restoredPremiumRay); + + return + IHubBase.PremiumDelta({ + sharesDelta: sharesDelta, + offsetRayDelta: offsetRayDelta, + restoredPremiumRay: restoredPremiumRay + }); + } +} diff --git a/invariants/hub-suite/handlers/interfaces/IHubAdminHandler.sol b/invariants/hub-suite/handlers/interfaces/IHubAdminHandler.sol new file mode 100644 index 000000000..948cac037 --- /dev/null +++ b/invariants/hub-suite/handlers/interfaces/IHubAdminHandler.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +/// @title IHubAdminHandler +/// @notice Interface for the HubAdminHandler — targets hub-level admin operations +/// that are not spoke-restricted: liquidity reinvestment (sweep/reclaim) +/// and fee share minting (mintFeeShares). +interface IHubAdminHandler { + function sweep(uint256 amount, uint8 i, uint8 j) external; + + function reclaim(uint256 amount, uint8 i, uint8 j) external; + + function mintFeeShares(uint8 i, uint8 j) external; +} diff --git a/invariants/hub-suite/handlers/interfaces/IHubConfiguratorHandler.sol b/invariants/hub-suite/handlers/interfaces/IHubConfiguratorHandler.sol new file mode 100644 index 000000000..e2a8a0e7e --- /dev/null +++ b/invariants/hub-suite/handlers/interfaces/IHubConfiguratorHandler.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +/// @title IHubConfiguratorHandler +/// @notice Interface for the HubConfiguratorHandler +interface IHubConfiguratorHandler { + function updateSpokeAddCap(uint256 addCap, uint8 i, uint8 j) external; + + function updateSpokeDrawCap(uint256 drawCap, uint8 i, uint8 j) external; + + function updateSpokeRiskPremiumThreshold(uint256 riskPremiumThreshold, uint8 i, uint8 j) external; + + function updateSpokeHalted(bool halted, uint8 i, uint8 j) external; + + function updateLiquidityFee(uint256 liquidityFee, uint8 i) external; +} diff --git a/invariants/hub-suite/handlers/interfaces/IHubHandler.sol b/invariants/hub-suite/handlers/interfaces/IHubHandler.sol new file mode 100644 index 000000000..098b72b27 --- /dev/null +++ b/invariants/hub-suite/handlers/interfaces/IHubHandler.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import {IHubBase} from 'src/hub/interfaces/IHub.sol'; + +/// @title IHubHandler +/// @notice Interface for hub handler actions +interface IHubHandler { + function add(uint256 amount, uint8 i) external returns (uint256 addedShares); + + function remove(uint256 amount, uint8 i) external returns (uint256 removedShares); + + function draw(uint256 amount, uint8 i) external returns (uint256 drawnShares); + + function restore( + uint256 drawnAmount, + uint256 premiumAmount, + int256 sharesDelta, + uint8 i + ) external returns (uint256 restoredDrawnShares); + + function reportDeficit( + uint256 drawnAmount, + uint256 premiumAmount, + int256 sharesDelta, + uint8 i + ) external; + + function eliminateDeficit(uint256 amount, uint8 i) external; + + function refreshPremium(int256 sharesDelta, uint8 i) external; + + function refreshPremiumBroad(IHubBase.PremiumDelta memory premiumDelta, uint8 i) external; + + function payFeeShares(uint256 shares, uint8 i) external; + + function transferShares(uint256 shares, uint8 i, uint8 j) external; + + function sweep(uint256 amount, uint8 i) external; + + function reclaim(uint256 amount, uint8 i) external; + + function mintFeeShares(uint8 i) external; + + function roundtrip_ERC4626_RT_A(uint256 amount, uint8 i) external; + + function roundtrip_ERC4626_RT_B(uint256 amount, uint8 i) external; + + function roundtrip_ERC4626_RT_C(uint256 shares, uint8 i) external; + + function roundtrip_ERC4626_RT_D(uint256 shares, uint8 i) external; + + function roundtrip_ERC4626_RT_E(uint256 shares, uint8 i) external; + + function roundtrip_ERC4626_RT_F(uint256 shares, uint8 i) external; + + function roundtrip_ERC4626_RT_G(uint256 amount, uint8 i) external; + + function roundtrip_ERC4626_RT_H(uint256 amount, uint8 i) external; +} diff --git a/invariants/hub-suite/handlers/simulators/DonationAttackHandler.t.sol b/invariants/hub-suite/handlers/simulators/DonationAttackHandler.t.sol new file mode 100644 index 000000000..4e35e3f0b --- /dev/null +++ b/invariants/hub-suite/handlers/simulators/DonationAttackHandler.t.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +// Test Contracts +import {BaseHandler} from '../../base/BaseHandler.t.sol'; +import {TestnetERC20} from 'tests/mocks/TestnetERC20.sol'; + +/// @title DonationAttackHandler +/// @notice Handler test contract for a set of actions +contract DonationAttackHandler is BaseHandler { + /////////////////////////////////////////////////////////////////////////////////////////////// + // STATE VARIABLES // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /////////////////////////////////////////////////////////////////////////////////////////////// + // ACTIONS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /////////////////////////////////////////////////////////////////////////////////////////////// + // OWNER ACTIONS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function donateUnderlyingToHub(uint256 amount, uint8 i) external { + address underlying = _getRandomBaseAsset(i); + + _before(); + TestnetERC20(underlying).mint(address(hub), amount); + _after(); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// +} diff --git a/invariants/hub-suite/hooks/DefaultBeforeAfterHooks.t.sol b/invariants/hub-suite/hooks/DefaultBeforeAfterHooks.t.sol new file mode 100644 index 000000000..20e86562d --- /dev/null +++ b/invariants/hub-suite/hooks/DefaultBeforeAfterHooks.t.sol @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +// Libraries +import {SharesMath} from 'src/hub/libraries/SharesMath.sol'; +import {MathUtils} from 'src/libraries/math/MathUtils.sol'; +import {WadRayMath} from 'src/libraries/math/WadRayMath.sol'; +import {PercentageMath} from 'src/libraries/math/PercentageMath.sol'; + +// Utils +import {Actor} from '../../shared/utils/Actor.sol'; +import {PropertiesConstants} from '../../shared/utils/PropertiesConstants.sol'; +import {StdAsserts} from '../../shared/utils/StdAsserts.sol'; + +// Interfaces +import {IHub} from 'src/hub/interfaces/IHub.sol'; +import {IAssetInterestRateStrategy} from 'src/hub/interfaces/IAssetInterestRateStrategy.sol'; +import {IHubHandler} from '../handlers/interfaces/IHubHandler.sol'; + +// Contracts +import {BaseHooks} from '../base/BaseHooks.t.sol'; + +/// @title DefaultBeforeAfterHooks +/// @notice Helper contract for before and after hooks, state variable caching and postconditions +/// @dev This contract is inherited by handlers +abstract contract DefaultBeforeAfterHooks is BaseHooks { + using WadRayMath for *; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // STRUCTS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + struct Debt { + uint256 drawn; + uint256 premium; + uint256 owed; + } + + struct AssetVars { + IHub.Asset asset; + uint256 drawnRate; + uint256 drawnIndex; + uint256 totalAssets; + uint256 totalShares; + Debt debt; + } + + struct SpokeDataVars { + IHub.SpokeData spokeData; + uint256 addedAssets; + uint256 addedShares; + Debt debt; + } + + struct DefaultVars { + mapping(uint256 assetId => AssetVars) assetVars; + mapping(uint256 assetId => mapping(address spoke => SpokeDataVars)) spokeDataVars; + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HOOKS STORAGE // + /////////////////////////////////////////////////////////////////////////////////////////////// + + // Default variables before and after + DefaultVars defaultVarsBefore; + DefaultVars defaultVarsAfter; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // SETUP // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Default hooks setup + function _setUpDefaultHooks() internal {} + + /// @notice Helper to initialize storage arrays of default vars + function _setUpDefaultVars(DefaultVars storage _defaultVars) internal {} + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HOOKS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function _defaultHooksBefore() internal { + // Asset values + _setAssetValues(defaultVarsBefore); + // Spoke data values + _setSpokeDataValues(defaultVarsBefore); + } + + function _defaultHooksAfter() internal { + // Asset values + _setAssetValues(defaultVarsAfter); + // Spoke data values + _setSpokeDataValues(defaultVarsAfter); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function _setAssetValues(DefaultVars storage vars) internal { + uint256 assetCount = hub.getAssetCount(); + for (uint256 i; i < assetCount; ++i) { + (uint256 drawn, uint256 premium) = hub.getAssetOwed(i); + vars.assetVars[i] = AssetVars({ + asset: hub.getAsset(i), + drawnRate: hub.getAssetDrawnRate(i), + drawnIndex: hub.getAssetDrawnIndex(i), + totalAssets: hub.getAddedAssets(i), + totalShares: hub.getAddedShares(i), + debt: Debt({drawn: drawn, premium: premium, owed: drawn + premium}) + }); + } + } + + function _setSpokeDataValues(DefaultVars storage vars) internal { + uint256 assetCount = hub.getAssetCount(); + for (uint256 i; i < assetCount; ++i) { + for (uint256 j; j < NUMBER_OF_ACTORS; ++j) { + address spoke = actors[j]; + (uint256 drawn, uint256 premium) = hub.getSpokeOwed(i, spoke); + vars.spokeDataVars[i][spoke] = SpokeDataVars({ + spokeData: hub.getSpoke(i, spoke), + addedAssets: hub.getSpokeAddedAssets(i, spoke), + addedShares: hub.getSpokeAddedShares(i, spoke), + debt: Debt({drawn: drawn, premium: premium, owed: drawn + premium}) + }); + } + } + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // POST CONDITIONS: HUB // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function assert_GPOST_HUB_A(uint256 assetId) internal { + AssetVars memory varsBefore = _assetVarsBefore(assetId); + AssetVars memory varsAfter = _assetVarsAfter(assetId); + assertGe(varsAfter.drawnIndex, varsBefore.drawnIndex, GPOST_HUB_A); + } + + function assert_GPOST_HUB_B(uint256 assetId) internal { + AssetVars memory varsBefore = _assetVarsBefore(assetId); + AssetVars memory varsAfter = _assetVarsAfter(assetId); + + assertFullMulGe( + varsAfter.totalAssets + SharesMath.VIRTUAL_ASSETS, + varsBefore.totalShares + SharesMath.VIRTUAL_SHARES, + varsBefore.totalAssets + SharesMath.VIRTUAL_ASSETS, + varsAfter.totalShares + SharesMath.VIRTUAL_SHARES, + GPOST_HUB_B + ); + } + + function assert_GPOST_HUB_C(uint256 assetId) internal { + // Read the cached signature of the current action + bytes4 signature = currentActionSignature; + if ( + signature == IHubHandler.add.selector || + signature == IHubHandler.remove.selector || + signature == IHubHandler.draw.selector || + signature == IHubHandler.restore.selector || + signature == IHubHandler.reportDeficit.selector || + signature == IHubHandler.sweep.selector || + signature == IHubHandler.reclaim.selector || + signature == IHubHandler.eliminateDeficit.selector || + signature == IHubHandler.refreshPremium.selector || + signature == IHubHandler.payFeeShares.selector || + signature == IHubHandler.transferShares.selector + ) { + AssetVars memory vars = _assetVarsAfter(assetId); + assertEq( + vars.drawnRate, + IAssetInterestRateStrategy(irStrategy).calculateInterestRate( + assetId, + vars.asset.liquidity, + vars.debt.drawn, + vars.asset.deficitRay.fromRayUp(), + vars.asset.swept + ), + GPOST_HUB_C + ); + } + } + + function assert_GPOST_HUB_D(uint256 assetId) internal { + assertLe(_assetVarsAfter(assetId).asset.lastUpdateTimestamp, block.timestamp, GPOST_HUB_D); + } + + function assert_GPOST_HUB_EF(uint256 assetId, address spoke) internal { + // Get the spoke config + IHub.SpokeConfig memory spokeConfig = hub.getSpokeConfig(assetId, spoke); + (, uint8 decimals) = hub.getAssetUnderlyingAndDecimals(assetId); + + // GPOST_HUB_E + SpokeDataVars memory spokeDataBefore = _spokeDataVarsBefore(assetId, spoke); + SpokeDataVars memory spokeDataAfter = _spokeDataVarsAfter(assetId, spoke); + + if ( + spokeDataAfter.addedAssets > spokeDataBefore.addedAssets && + spokeDataAfter.addedShares != spokeDataBefore.addedShares && + spokeDataBefore.addedShares != 0 /// @dev required to avoid interest accrual detection + ) { + if (spokeConfig.addCap != MAX_ALLOWED_SPOKE_CAP) { + assertLe( + spokeDataAfter.addedAssets, + spokeConfig.addCap * MathUtils.uncheckedExp(10, decimals), + GPOST_HUB_E + ); + } + } + + // GPOST_HUB_F + if (spokeDataAfter.debt.owed > spokeDataBefore.debt.owed) { + if (spokeConfig.drawCap != MAX_ALLOWED_SPOKE_CAP) { + assertLe( + spokeDataAfter.debt.owed + spokeDataAfter.spokeData.deficitRay.fromRayUp(), + spokeConfig.drawCap * MathUtils.uncheckedExp(10, decimals), + GPOST_HUB_F + ); + } + } + } + + function assert_GPOST_HUB_G(uint256 assetId) internal { + assertGe( + _assetVarsAfter(assetId).asset.lastUpdateTimestamp, + _assetVarsBefore(assetId).asset.lastUpdateTimestamp, + GPOST_HUB_G + ); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + /// @dev Only use these helpers in postConditions, do NOT rely on them at Invariants because they not be populated + /// when the fuzzer has updated env (eg block.timestamp) which does not invoke any Handler + + function _cacheCurrentActionSignature() internal { + currentActionSignature = bytes4(msg.sig); + } + + function _assetVarsBefore(uint256 assetId) internal view returns (AssetVars memory) { + return defaultVarsBefore.assetVars[assetId]; + } + + function _assetVarsAfter(uint256 assetId) internal view returns (AssetVars memory) { + return defaultVarsAfter.assetVars[assetId]; + } + + function _spokeDataVarsBefore( + uint256 assetId, + address spoke + ) internal view returns (SpokeDataVars memory) { + return defaultVarsBefore.spokeDataVars[assetId][spoke]; + } + + function _spokeDataVarsAfter( + uint256 assetId, + address spoke + ) internal view returns (SpokeDataVars memory) { + return defaultVarsAfter.spokeDataVars[assetId][spoke]; + } +} diff --git a/invariants/hub-suite/hooks/HookAggregator.t.sol b/invariants/hub-suite/hooks/HookAggregator.t.sol new file mode 100644 index 000000000..e088a13b7 --- /dev/null +++ b/invariants/hub-suite/hooks/HookAggregator.t.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +// Hook Contracts +import {DefaultBeforeAfterHooks} from './DefaultBeforeAfterHooks.t.sol'; + +// Utils +import {ErrorHandlers} from '../../shared/utils/ErrorHandlers.sol'; + +/// @title HookAggregator +/// @notice Helper contract to aggregate all before / after hook contracts, inherited on each handler +abstract contract HookAggregator is DefaultBeforeAfterHooks { + /////////////////////////////////////////////////////////////////////////////////////////////// + // SETUP // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Initializer for the hooks + function _setUpHooks() internal { + _setUpDefaultHooks(); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HOOKS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Before hook for the handlers + function _before() internal { + _defaultHooksBefore(); + } + + /// @notice After hook for the handlers + function _after() internal { + _defaultHooksAfter(); + + // POST-CONDITIONS + _checkPostConditions(); + + // Reset the state + _resetState(); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // POSTCONDITION CHECKS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Postconditions for the handlers + function _checkPostConditions() internal { + // Store the message signature to avoid losing it inside the checkPostConditions call context + _cacheCurrentActionSignature(); + + try this.checkPostConditions() {} catch (bytes memory ret) { + ErrorHandlers.handleAssertionError(false, ret, true, GPOST_CHECK_FAILED); + } + } + + /// @dev postconditions checks entrypoint, should be self-called + function checkPostConditions() external { + // Protocol-wide postconditions + uint256 assetCount = hub.getAssetCount(); + for (uint256 i; i < assetCount; i++) { + assert_GPOST_HUB_A(i); + assert_GPOST_HUB_B(i); + assert_GPOST_HUB_C(i); + assert_GPOST_HUB_D(i); + assert_GPOST_HUB_G(i); + + for (uint256 j; j < NUMBER_OF_ACTORS; j++) { + address spoke = actors[j]; + assert_GPOST_HUB_EF(i, spoke); + } + } + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Resets the state of the handlers + function _resetState() internal { + delete currentActionSignature; + } +} diff --git a/invariants/hub-suite/invariants/HubInvariantAssertions.t.sol b/invariants/hub-suite/invariants/HubInvariantAssertions.t.sol new file mode 100644 index 000000000..bbbf94a1e --- /dev/null +++ b/invariants/hub-suite/invariants/HubInvariantAssertions.t.sol @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +// Libraries +import {Premium} from 'src/hub/libraries/Premium.sol'; +import {SharesMath} from 'src/hub/libraries/SharesMath.sol'; +import {WadRayMath} from 'src/libraries/math/WadRayMath.sol'; +import {SafeCast} from 'src/dependencies/openzeppelin/SafeCast.sol'; + +// Interfaces +import {IHub} from 'src/hub/interfaces/IHub.sol'; +import {IERC20} from 'src/dependencies/openzeppelin/IERC20.sol'; + +// Specs +import {HubInvariantsSpec} from '../specs/HubInvariantsSpec.t.sol'; + +// Assertions +import {StdAsserts} from '../../shared/utils/StdAsserts.sol'; + +/// @title HubInvariantAssertions +/// @notice Abstract hub invariant assertion logic, importable by any suite. +/// @dev Does not inherit any suite-specific base class. Concrete suites override +/// `_getSpokesForAsset` to supply the spoke list for iteration. +abstract contract HubInvariantAssertions is StdAsserts, HubInvariantsSpec { + using SafeCast for *; + using WadRayMath for *; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // STATEFUL INVARIANT STORAGE // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Last seen drawn index per hub per assetId (INV_HUB_Q) + mapping(IHub => mapping(uint256 => uint256)) internal _lastSeenDebtSharePrice; + /// @notice Last seen added assets (+ virtual) per hub per assetId (INV_HUB_R) + mapping(IHub => mapping(uint256 => uint256)) internal _lastSeenAssets; + /// @notice Last seen added shares (+ virtual) per hub per assetId (INV_HUB_R) + mapping(IHub => mapping(uint256 => uint256)) internal _lastSeenShares; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // VIRTUAL HOOKS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @dev Returns the list of spokes to iterate for a given hub and asset. + /// Hub-suite returns actorAddresses + feeReceiver; protocol-suite returns allSpokes. + function _getSpokesForAsset( + IHub hub, + uint256 assetId + ) internal view virtual returns (address[] memory); + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HUB INVARIANTS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function assert_INV_HUB_A(IHub hub, uint256 assetId) internal { + uint256 assets = hub.getAddedAssets(assetId); + + if (assets == 0) { + assertEq(hub.getAddedShares(assetId), 0, INV_HUB_A); + } + } + + function assert_INV_HUB_B(IHub hub, uint256 assetId) internal { + address[] memory spokes = _getSpokesForAsset(hub, assetId); + uint256 spokeCount = spokes.length; + uint256 sumDebt; + + for (uint256 i; i < spokeCount; i++) { + sumDebt += hub.getSpokeTotalOwed(assetId, spokes[i]); + } + + uint256 assetTotal = hub.getAssetTotalOwed(assetId); + assertGe(sumDebt, assetTotal, INV_HUB_B); + } + + function assert_INV_HUB_C(IHub hub, uint256 assetId) internal { + address[] memory spokes = _getSpokesForAsset(hub, assetId); + uint256 spokeCount = spokes.length; + + uint256 sumDrawnShares; + uint256 sumPremDrawnShares; + int256 sumPremOffsetRay; + + for (uint256 i; i < spokeCount; i++) { + address spoke = spokes[i]; + sumDrawnShares += hub.getSpokeDrawnShares(assetId, spoke); + (uint256 premiumDrawnShares, int256 premiumOffsetRay) = hub.getSpokePremiumData( + assetId, + spoke + ); + sumPremDrawnShares += premiumDrawnShares; + sumPremOffsetRay += premiumOffsetRay; + } + + // Asset totals + IHub.Asset memory asset = hub.getAsset(assetId); + + // Checks + assertEq(sumDrawnShares, asset.drawnShares, INV_HUB_C); + assertEq(sumPremDrawnShares, asset.premiumShares, INV_HUB_C); + assertEq(sumPremOffsetRay, asset.premiumOffsetRay, INV_HUB_C); + } + + function assert_INV_HUB_E(IHub hub, uint256 assetId) internal { + uint256 totalAssets = hub.getAddedAssets(assetId); + uint256 totalShares = hub.getAddedShares(assetId); + + // interest accrued on virtual shares + uint256 burntInterest = totalAssets - hub.previewRemoveByShares(assetId, totalShares); + + // Checks: totalAddedAssets ≈ previewRemoveByShares(totalAddedShares) + // Tolerance: the virtual offset (V=1e6) absorbs a fraction of accrued interest when + // converting all shares back to assets. + assertApproxEqAbs( + totalAssets, + hub.previewRemoveByShares(assetId, totalShares), // round down + burntInterest, + INV_HUB_E_1 + ); + + assertGe( + totalAssets + SharesMath.VIRTUAL_ASSETS, + hub.previewRemoveByShares(assetId, totalShares + SharesMath.VIRTUAL_ASSETS), + INV_HUB_E_2 + ); + } + + function assert_INV_HUB_F(IHub hub, uint256 assetId) internal { + uint256 totalAssets = hub.getAddedAssets(assetId); + uint256 accruedFees = hub.getAssetAccruedFees(assetId); + + IHub.Asset memory asset = hub.getAsset(assetId); + uint256 drawnIndex = hub.getAssetDrawnIndex(assetId); + + uint256 premiumRay = Premium.calculatePremiumRay({ + premiumShares: asset.premiumShares, + premiumOffsetRay: asset.premiumOffsetRay, + drawnIndex: drawnIndex + }); + uint256 drawnRay = asset.drawnShares * drawnIndex; + uint256 aggregatedOwed = (drawnRay + premiumRay + asset.deficitRay).fromRayUp(); + + assertEq(totalAssets + accruedFees, asset.liquidity + aggregatedOwed + asset.swept, INV_HUB_F); + } + + function assert_INV_HUB_GH(IHub hub, uint256 assetId) internal { + address[] memory spokes = _getSpokesForAsset(hub, assetId); + uint256 spokeCount = spokes.length; + uint256 tolerancePerActor = hub.previewAddByShares(assetId, 1); + + // Sum per-spoke values + uint256 totalAddedAssets; + uint256 totalAddedShares; + for (uint256 i; i < spokeCount; i++) { + totalAddedAssets += hub.getSpokeAddedAssets(assetId, spokes[i]); + totalAddedShares += hub.getSpokeAddedShares(assetId, spokes[i]); + } + + // Inline burnt interest: interest accrued on virtual shares + { + uint256 totalAssets = hub.getAddedAssets(assetId); + uint256 totalShares = hub.getAddedShares(assetId); + totalAddedAssets += totalAssets - hub.previewRemoveByShares(assetId, totalShares); + } + + // Checks + uint256 addedShares = hub.getAddedShares(assetId); + if (addedShares > 0) { + assertApproxEqAbs( + totalAddedAssets, + hub.getAddedAssets(assetId), + (spokeCount + 2) * tolerancePerActor, + INV_HUB_G + ); + } + assertEq(totalAddedShares, hub.getAddedShares(assetId), INV_HUB_H); + } + + function assert_INV_HUB_I(IHub hub, uint256 assetId) internal { + // Get underlying from assetId + (address underlying, ) = hub.getAssetUnderlyingAndDecimals(assetId); + + // Query values + uint256 liquidity = hub.getAssetLiquidity(assetId); + uint256 swept = hub.getAssetSwept(assetId); + uint256 underlyingBalance = IERC20(underlying).balanceOf(address(hub)); + + // Checks + assertGe(underlyingBalance + swept, liquidity, INV_HUB_I); + } + + function assert_INV_HUB_K(IHub hub, uint256 assetId) internal { + IHub.AssetConfig memory assetConfig = hub.getAssetConfig(assetId); + + // Checks + assertTrue(assetConfig.irStrategy != address(0), INV_HUB_K); + } + + function assert_INV_HUB_O(IHub hub, uint256 assetId) internal { + address[] memory spokes = _getSpokesForAsset(hub, assetId); + uint256 spokeCount = spokes.length; + uint256 totalDeficitRay; + for (uint256 i; i < spokeCount; i++) { + totalDeficitRay += hub.getSpokeDeficitRay(assetId, spokes[i]); + } + assertEq(totalDeficitRay, hub.getAssetDeficitRay(assetId), INV_HUB_O); + } + + function assert_INV_HUB_P(IHub hub, uint256 assetId) internal { + (uint256 premiumShares, int256 premiumOffsetRay) = hub.getAssetPremiumData(assetId); + uint256 drawnIndex = hub.getAssetDrawnIndex(assetId); + assertGe((premiumShares * drawnIndex).toInt256(), premiumOffsetRay, INV_HUB_P); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // STATEFUL HUB INVARIANTS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function assert_INV_HUB_Q(IHub hub, uint256 assetId) internal { + uint256 lastIndex = _lastSeenDebtSharePrice[hub][assetId]; + uint256 currentIndex = hub.getAssetDrawnIndex(assetId); + assertGe(currentIndex, WadRayMath.RAY, INV_HUB_Q); + if (lastIndex > 0) { + assertGe(currentIndex, lastIndex, INV_HUB_Q); + } + _lastSeenDebtSharePrice[hub][assetId] = currentIndex; + } + + function assert_INV_HUB_R(IHub hub, uint256 assetId) internal { + uint256 lastAssets = _lastSeenAssets[hub][assetId]; + uint256 lastShares = _lastSeenShares[hub][assetId]; + uint256 assets = hub.getAddedAssets(assetId) + SharesMath.VIRTUAL_ASSETS; + uint256 shares = hub.getAddedShares(assetId) + SharesMath.VIRTUAL_SHARES; + if (lastShares > 0) { + // assets/shares >= lastAssets/lastShares <=> assets * lastShares >= lastAssets * shares + assertFullMulGe(assets, lastShares, lastAssets, shares, INV_HUB_R); + } + _lastSeenAssets[hub][assetId] = assets; + _lastSeenShares[hub][assetId] = shares; + } +} diff --git a/invariants/hub-suite/invariants/HubInvariants.t.sol b/invariants/hub-suite/invariants/HubInvariants.t.sol new file mode 100644 index 000000000..3ff901fbb --- /dev/null +++ b/invariants/hub-suite/invariants/HubInvariants.t.sol @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +// Interfaces +import {IHub} from 'src/hub/interfaces/IHub.sol'; + +// Contracts +import {HubInvariantAssertions} from './HubInvariantAssertions.t.sol'; +import {HandlerAggregator} from '../HandlerAggregator.t.sol'; + +/// @title HubInvariants +/// @notice Implements Hub Invariants for the hub-suite. +/// @dev Common invariant assertions are inherited from HubInvariantAssertions. +/// This contract adds ERC4626 and AVAILABILITY invariants specific to the hub-suite. +abstract contract HubInvariants is HandlerAggregator, HubInvariantAssertions { + /////////////////////////////////////////////////////////////////////////////////////////////// + // VIRTUAL OVERRIDES // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @dev Returns actorAddresses + feeReceiver for the given hub and asset. + function _getSpokesForAsset( + IHub hub, + uint256 assetId + ) internal view override returns (address[] memory) { + address feeReceiver = hub.getAsset(assetId).feeReceiver; + uint256 count = NUMBER_OF_ACTORS; + address[] memory spokes = new address[](count + 1); + for (uint256 i; i < count; i++) { + spokes[i] = actors[i]; + } + spokes[count] = feeReceiver; + return spokes; + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HUB: ERC4626 // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function assert_INV_HUB_ERC4626_A(uint256 assetId, address spoke) internal { + uint256 addedAssets = hub.getSpokeAddedAssets(assetId, spoke); + uint256 addedShares = hub.getSpokeAddedShares(assetId, spoke); + uint256 premiumRay = hub.getAssetPremiumRay(assetId); + // since premium can be incurred without drawing liquidity + if (addedAssets != 0 && premiumRay == 0) { + assertTrue(addedShares != 0, INV_HUB_ERC4626_A); + } + } + + function assert_INV_HUB_ERC4626_B(uint256 assetId, address spoke) internal { + (uint256 drawnAssets, ) = hub.getSpokeOwed(assetId, spoke); + uint256 drawnShares = hub.getSpokeDrawnShares(assetId, spoke); + if (drawnAssets != 0) assertTrue(drawnShares != 0, INV_HUB_ERC4626_B); + } + + function assert_INV_HUB_ERC4626_C(uint256 assetId) internal { + uint256 addedAssets = hub.getAddedAssets(assetId); + uint256 addedShares = hub.getAddedShares(assetId); + uint256 premiumRay = hub.getAssetPremiumRay(assetId); + // since premium can be incurred without drawing liquidity + if (addedAssets != 0 && premiumRay == 0) { + assertTrue(addedShares != 0, INV_HUB_ERC4626_C); + } + } + + function assert_INV_HUB_ERC4626_D(uint256 assetId) internal { + (uint256 drawnAssets, ) = hub.getAssetOwed(assetId); + uint256 drawnShares = hub.getAssetDrawnShares(assetId); + if (drawnAssets != 0) assertTrue(drawnShares != 0, INV_HUB_ERC4626_D); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HUB: AVAILABILITY // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function assert_INV_HUB_AVAILABILITY_A(uint256 assetId) internal { + try hub.getAddedAssets(assetId) {} catch { + assertTrue(false, INV_HUB_AVAILABILITY_A); + } + } + + function assert_INV_HUB_AVAILABILITY_B(uint256 assetId) internal { + try hub.getAssetOwed(assetId) {} catch { + assertTrue(false, INV_HUB_AVAILABILITY_B); + } + } + + function assert_INV_HUB_AVAILABILITY_C(uint256 assetId) internal { + try hub.getAssetTotalOwed(assetId) {} catch { + assertTrue(false, INV_HUB_AVAILABILITY_C); + } + } + + function assert_INV_HUB_AVAILABILITY_D(uint256 assetId) internal { + try hub.getAssetPremiumRay(assetId) {} catch { + assertTrue(false, INV_HUB_AVAILABILITY_D); + } + } + + function assert_INV_HUB_AVAILABILITY_E(uint256 assetId) internal { + try hub.getAssetAccruedFees(assetId) {} catch { + assertTrue(false, INV_HUB_AVAILABILITY_E); + } + } + + function assert_INV_HUB_AVAILABILITY_F(uint256 assetId, address spoke) internal { + try hub.getSpokeAddedAssets(assetId, spoke) {} catch { + assertTrue(false, INV_HUB_AVAILABILITY_F); + } + } + + function assert_INV_HUB_AVAILABILITY_G(uint256 assetId, address spoke) internal { + try hub.getSpokeOwed(assetId, spoke) {} catch { + assertTrue(false, INV_HUB_AVAILABILITY_G); + } + } + + function assert_INV_HUB_AVAILABILITY_H(uint256 assetId, address spoke) internal { + try hub.getSpokeTotalOwed(assetId, spoke) {} catch { + assertTrue(false, INV_HUB_AVAILABILITY_H); + } + } + + function assert_INV_HUB_AVAILABILITY_I(uint256 assetId, address spoke) internal { + try hub.getSpokePremiumRay(assetId, spoke) {} catch { + assertTrue(false, INV_HUB_AVAILABILITY_I); + } + } +} diff --git a/invariants/hub-suite/replays/ReplayTest_1.t.sol b/invariants/hub-suite/replays/ReplayTest_1.t.sol new file mode 100644 index 000000000..6bec9439a --- /dev/null +++ b/invariants/hub-suite/replays/ReplayTest_1.t.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +// Libraries +import 'forge-std/Test.sol'; +import 'forge-std/console.sol'; + +// Contracts +import {Invariants} from '../Invariants.t.sol'; +import {Setup} from '../Setup.t.sol'; + +// Utils +import {Actor} from '../../shared/utils/Actor.sol'; + +contract ReplayTest1 is Invariants, Setup { + // Generated from Echidna reproducers + + // Target contract instance (you may need to adjust this) + ReplayTest1 Tester = this; + + modifier setup() override { + _; + } + + function setUp() public { + // Deploy protocol contracts + _setUp(); + + /// @dev fixes the actor to the first user + actor = userToActor[USER1]; + + vm.warp(101007); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // REPLAY TESTS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @dev PASS + function test_replay_1_add() public { + _setUpActor(USER3); + _delay(67960); + Tester.updateSpokeHalted(true, 26, 105); + _delay(289607); + Tester.add(1524785992, 171); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Fast forward the time and set up an actor, + /// @dev Use for ECHIDNA call-traces + function _delay(uint256 _seconds) internal { + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up an actor + function _setUpActor(address _origin) internal { + actor = userToActor[_origin]; + } + + /// @notice Set up an actor and fast forward the time + /// @dev Use for ECHIDNA call-traces + function _setUpActorAndDelay(address _origin, uint256 _seconds) internal { + actor = userToActor[_origin]; + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up a specific block and actor + function _setUpBlockAndActor(uint256 _block, address _user) internal { + vm.roll(_block); + actor = userToActor[_user]; + } + + /// @notice Set up a specific timestamp and actor + function _setUpTimestampAndActor(uint256 _timestamp, address _user) internal { + vm.warp(_timestamp); + actor = userToActor[_user]; + } +} diff --git a/invariants/hub-suite/replays/ReplayTest_2.t.sol b/invariants/hub-suite/replays/ReplayTest_2.t.sol new file mode 100644 index 000000000..a123a45c1 --- /dev/null +++ b/invariants/hub-suite/replays/ReplayTest_2.t.sol @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +// Libraries +import 'forge-std/Test.sol'; +import 'forge-std/console.sol'; + +// Contracts +import {Invariants} from '../Invariants.t.sol'; +import {Setup} from '../Setup.t.sol'; + +// Utils +import {Actor} from '../../shared/utils/Actor.sol'; + +contract ReplayTest2Hub is Invariants, Setup { + // Generated from Echidna reproducers + + // Target contract instance (you may need to adjust this) + ReplayTest2Hub Tester = this; + + modifier setup() override { + _; + } + + function setUp() public { + // Deploy protocol contracts + _setUp(); + + /// @dev fixes the actor to the first user + actor = userToActor[USER1]; + + vm.warp(101007); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // REPLAY TESTS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @dev Replays a low-liquidity scenario where virtual assets/shares skew exchange rate. + /// Early redeemers get more assets per share than late redeemers - accepted by design. + function test_replay_2_add() public { + _setUpActor(USER1); + Tester.add(1, 0); + Tester.draw(1, 0); + _delay(1); + Tester.add(3, 0); + } + + /// @dev PASS + function test_replay_2_payFeeShares() public { + _setUpActor(USER1); + Tester.add(1, 0); + Tester.add(2, 1); + Tester.draw(1, 1); + _delay(1); + Tester.payFeeShares(1, 0); + _checkAllHubInvariants(); + } + + /// @dev PASS + function test_replay_2_remove() public { + _setUpActor(USER1); + Tester.add(2, 1); + Tester.add(1, 0); + Tester.draw(1, 1); + _delay(1); + Tester.remove(1, 0); + } + + /// @dev PASS + function test_replay_2_transferShares() public { + _setUpActor(USER1); + Tester.add(1, 1); + Tester.add(2, 0); + Tester.draw(1, 0); + _delay(1); + Tester.transferShares(1, 1, 0); + } + + /// @dev PASS + function test_replay_2_draw() public { + _setUpActor(USER1); + Tester.add(1, 0); + Tester.add(2, 1); + Tester.draw(1, 1); + _delay(1); + Tester.draw(1, 0); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Fast forward the time and set up an actor, + /// @dev Use for ECHIDNA call-traces + function _delay(uint256 _seconds) internal { + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up an actor + function _setUpActor(address _origin) internal { + actor = userToActor[_origin]; + } + + /// @notice Set up an actor and fast forward the time + /// @dev Use for ECHIDNA call-traces + function _setUpActorAndDelay(address _origin, uint256 _seconds) internal { + actor = userToActor[_origin]; + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up a specific block and actor + function _setUpBlockAndActor(uint256 _block, address _user) internal { + vm.roll(_block); + actor = userToActor[_user]; + } + + /// @notice Set up a specific timestamp and actor + function _setUpTimestampAndActor(uint256 _timestamp, address _user) internal { + vm.warp(_timestamp); + actor = userToActor[_user]; + } +} diff --git a/invariants/hub-suite/replays/ReplayTest_3.t.sol b/invariants/hub-suite/replays/ReplayTest_3.t.sol new file mode 100644 index 000000000..8f9695c2f --- /dev/null +++ b/invariants/hub-suite/replays/ReplayTest_3.t.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +// Libraries +import 'forge-std/Test.sol'; +import 'forge-std/console.sol'; + +// Contracts +import {Invariants} from '../Invariants.t.sol'; +import {Setup} from '../Setup.t.sol'; + +// Utils +import {Actor} from '../../shared/utils/Actor.sol'; + +contract ReplayTest3Hub is Invariants, Setup { + // Generated from Echidna reproducers + + // Target contract instance (you may need to adjust this) + ReplayTest3Hub Tester = this; + + modifier setup() override { + _; + } + + function setUp() public { + // Deploy protocol contracts + _setUp(); + + /// @dev fixes the actor to the first user + actor = userToActor[USER1]; + + vm.warp(101007); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // REPLAY TESTS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @dev FAIL + function test_replay_3_add() public { + _setUpActor(USER1); + Tester.add(1, 0); + Tester.draw(1, 0); + _delay(6343); + Tester.add(6, 0); + _checkAllHubInvariants(); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Fast forward the time and set up an actor, + /// @dev Use for ECHIDNA call-traces + function _delay(uint256 _seconds) internal { + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up an actor + function _setUpActor(address _origin) internal { + actor = userToActor[_origin]; + } + + /// @notice Set up an actor and fast forward the time + /// @dev Use for ECHIDNA call-traces + function _setUpActorAndDelay(address _origin, uint256 _seconds) internal { + actor = userToActor[_origin]; + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up a specific block and actor + function _setUpBlockAndActor(uint256 _block, address _user) internal { + vm.roll(_block); + actor = userToActor[_user]; + } + + /// @notice Set up a specific timestamp and actor + function _setUpTimestampAndActor(uint256 _timestamp, address _user) internal { + vm.warp(_timestamp); + actor = userToActor[_user]; + } +} diff --git a/invariants/hub-suite/replays/ReplayTest_4.t.sol b/invariants/hub-suite/replays/ReplayTest_4.t.sol new file mode 100644 index 000000000..857bf901a --- /dev/null +++ b/invariants/hub-suite/replays/ReplayTest_4.t.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +// Libraries +import 'forge-std/Test.sol'; +import 'forge-std/console.sol'; + +// Contracts +import {Invariants} from '../Invariants.t.sol'; +import {Setup} from '../Setup.t.sol'; + +// Utils +import {Actor} from '../../shared/utils/Actor.sol'; + +contract ReplayTest4 is Invariants, Setup { + // Generated from Echidna reproducers + + // Target contract instance (you may need to adjust this) + ReplayTest4 Tester = this; + + modifier setup() override { + _; + } + + function setUp() public { + // Deploy protocol contracts + _setUp(); + + /// @dev fixes the actor to the first user + actor = userToActor[USER1]; + + vm.warp(101007); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // REPLAY TESTS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @dev PASS + function test_replay_4_add() public { + _setUpActor(USER3); + Tester.add(1012156, 0); + _setUpActor(USER1); + Tester.draw(1, 0); + _delay(150); + Tester.updateSpokeAddCap(0, 9, 50); + Tester.add(9, 0); + } + + /// @dev PASS + function test_replay_4_draw() public { + _setUpActor(USER1); + Tester.add(926436254396264375725066, 1); + Tester.updateSpokeAddCap(0, 1, 0); + Tester.draw(68, 1); + _delay(659719); + Tester.draw(403197591059359279380077, 1); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Fast forward the time and set up an actor, + /// @dev Use for ECHIDNA call-traces + function _delay(uint256 _seconds) internal { + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up an actor + function _setUpActor(address _origin) internal { + actor = userToActor[_origin]; + } + + /// @notice Set up an actor and fast forward the time + /// @dev Use for ECHIDNA call-traces + function _setUpActorAndDelay(address _origin, uint256 _seconds) internal { + actor = userToActor[_origin]; + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up a specific block and actor + function _setUpBlockAndActor(uint256 _block, address _user) internal { + vm.roll(_block); + actor = userToActor[_user]; + } + + /// @notice Set up a specific timestamp and actor + function _setUpTimestampAndActor(uint256 _timestamp, address _user) internal { + vm.warp(_timestamp); + actor = userToActor[_user]; + } +} diff --git a/invariants/hub-suite/replays/ReplayTest_5.t.sol b/invariants/hub-suite/replays/ReplayTest_5.t.sol new file mode 100644 index 000000000..b662d644c --- /dev/null +++ b/invariants/hub-suite/replays/ReplayTest_5.t.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +// Libraries +import 'forge-std/Test.sol'; + +// Contracts +import {Invariants} from '../Invariants.t.sol'; +import {Setup} from '../Setup.t.sol'; + +// Utils +import {Actor} from '../../shared/utils/Actor.sol'; + +contract ReplayTest5Hub is Invariants, Setup { + // Generated from Echidna reproducers + + // Target contract instance (you may need to adjust this) + ReplayTest5Hub Tester = this; + + modifier setup() override { + _; + } + + function setUp() public { + // Deploy protocol contracts + _setUp(); + + /// @dev fixes the actor to the first user + actor = userToActor[USER1]; + + vm.warp(101007); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // REPLAY TESTS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice BUG: refreshPremium can be called without drawnShares, creating phantom premium + /// that accrues over time and breaks INV_HUB_ERC4626_C (assets > 0 with shares == 0) + function test_replay_5_donateUnderlyingToHub() public { + _setUpActor(USER1); + Tester.refreshPremium(9472849991, 0); + _delay(1); + _checkAllHubInvariants(); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Fast forward the time and set up an actor, + /// @dev Use for ECHIDNA call-traces + function _delay(uint256 _seconds) internal { + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up an actor + function _setUpActor(address _origin) internal { + actor = userToActor[_origin]; + } + + /// @notice Set up an actor and fast forward the time + /// @dev Use for ECHIDNA call-traces + function _setUpActorAndDelay(address _origin, uint256 _seconds) internal { + actor = userToActor[_origin]; + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up a specific block and actor + function _setUpBlockAndActor(uint256 _block, address _user) internal { + vm.roll(_block); + actor = userToActor[_user]; + } + + /// @notice Set up a specific timestamp and actor + function _setUpTimestampAndActor(uint256 _timestamp, address _user) internal { + vm.warp(_timestamp); + actor = userToActor[_user]; + } +} diff --git a/invariants/hub-suite/replays/ReplayTest_6.t.sol b/invariants/hub-suite/replays/ReplayTest_6.t.sol new file mode 100644 index 000000000..a94233116 --- /dev/null +++ b/invariants/hub-suite/replays/ReplayTest_6.t.sol @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +// Libraries +import 'forge-std/Test.sol'; +import 'forge-std/console.sol'; + +// Contracts +import {Invariants} from '../Invariants.t.sol'; +import {Setup} from '../Setup.t.sol'; + +// Utils +import {Actor} from '../../shared/utils/Actor.sol'; + +contract ReplayTest6 is Invariants, Setup { + // Generated from Echidna reproducers + + // Target contract instance (you may need to adjust this) + ReplayTest6 Tester = this; + + modifier setup() override { + _; + } + + function setUp() public { + // Deploy protocol contracts + _setUp(); + + /// @dev fixes the actor to the first user + actor = userToActor[USER1]; + + vm.warp(101007); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // REPLAY TESTS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @dev PASS + function test_replay_6_restore() public { + _setUpActor(USER1); + Tester.refreshPremium(1310678, 2); + _delay(4813); + Tester.restore(0, 2, 0, 2); + } + + /// @dev PASS + function test_replay_6_draw() public { + _setUpActor(USER1); + Tester.add(1, 0); + Tester.refreshPremium(1, 0); + _delay(1); + Tester.draw(1, 0); + } + + /// @dev PASS + function test_replay_6_roundtrip_ERC4626_RT_D() public { + _setUpActor(USER1); + Tester.add(2, 0); + Tester.draw(1, 0); + _delay(1); + Tester.roundtrip_ERC4626_RT_D(1, 0); + } + + /// @dev PASS + function test_replay_6_roundtrip_ERC4626_RT_B() public { + _setUpActor(USER1); + Tester.add(1, 1); + } + + /// @dev PASS + function test_replay_6_roundtrip_ERC4626_RT_C() public { + _setUpActor(USER1); + Tester.refreshPremium(1, 0); + _delay(1); + Tester.roundtrip_ERC4626_RT_C(1, 0); + } + + /// @dev PASS + function test_replay_6_remove() public { + _setUpActor(USER1); + Tester.add(563, 0); + Tester.refreshPremium(34015034, 0); + _delay(174592); + Tester.remove(5, 0); + } + + /// @dev PASS + function test_replay_6_add() public { + _setUpActor(USER1); + Tester.add(1, 1); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Fast forward the time and set up an actor, + /// @dev Use for ECHIDNA call-traces + function _delay(uint256 _seconds) internal { + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up an actor + function _setUpActor(address _origin) internal { + actor = userToActor[_origin]; + } + + /// @notice Set up an actor and fast forward the time + /// @dev Use for ECHIDNA call-traces + function _setUpActorAndDelay(address _origin, uint256 _seconds) internal { + actor = userToActor[_origin]; + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up a specific block and actor + function _setUpBlockAndActor(uint256 _block, address _user) internal { + vm.roll(_block); + actor = userToActor[_user]; + } + + /// @notice Set up a specific timestamp and actor + function _setUpTimestampAndActor(uint256 _timestamp, address _user) internal { + vm.warp(_timestamp); + actor = userToActor[_user]; + } +} diff --git a/invariants/hub-suite/replays/ReplayTest_7.t.sol b/invariants/hub-suite/replays/ReplayTest_7.t.sol new file mode 100644 index 000000000..82bfdf388 --- /dev/null +++ b/invariants/hub-suite/replays/ReplayTest_7.t.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +// Libraries +import 'forge-std/Test.sol'; +import 'forge-std/console.sol'; + +// Contracts +import {Invariants} from '../Invariants.t.sol'; +import {Setup} from '../Setup.t.sol'; + +// Utils +import {Actor} from '../../shared/utils/Actor.sol'; + +contract ReplayTest7 is Invariants, Setup { + // Generated from Echidna reproducers + + // Target contract instance (you may need to adjust this) + ReplayTest7 Tester = this; + + modifier setup() override { + _; + } + + function setUp() public { + // Deploy protocol contracts + _setUp(); + + /// @dev fixes the actor to the first user + actor = userToActor[USER1]; + + vm.warp(101007); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // REPLAY TESTS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function test_replay_7_draw() public { + _setUpActor(USER1); + Tester.add(1, 0); + Tester.refreshPremium(1, 0); + _delay(1); + Tester.draw(1, 0); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Fast forward the time and set up an actor, + /// @dev Use for ECHIDNA call-traces + function _delay(uint256 _seconds) internal { + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up an actor + function _setUpActor(address _origin) internal { + actor = userToActor[_origin]; + } + + /// @notice Set up an actor and fast forward the time + /// @dev Use for ECHIDNA call-traces + function _setUpActorAndDelay(address _origin, uint256 _seconds) internal { + actor = userToActor[_origin]; + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up a specific block and actor + function _setUpBlockAndActor(uint256 _block, address _user) internal { + vm.roll(_block); + actor = userToActor[_user]; + } + + /// @notice Set up a specific timestamp and actor + function _setUpTimestampAndActor(uint256 _timestamp, address _user) internal { + vm.warp(_timestamp); + actor = userToActor[_user]; + } +} diff --git a/invariants/hub-suite/replays/ReplayTest_8.t.sol b/invariants/hub-suite/replays/ReplayTest_8.t.sol new file mode 100644 index 000000000..0fec3db4e --- /dev/null +++ b/invariants/hub-suite/replays/ReplayTest_8.t.sol @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +// Libraries +import 'forge-std/Test.sol'; +import 'forge-std/console.sol'; + +// Contracts +import {Invariants} from '../Invariants.t.sol'; +import {Setup} from '../Setup.t.sol'; + +// Utils +import {Actor} from '../../shared/utils/Actor.sol'; + +contract ReplayTest8 is Invariants, Setup { + // Generated from Echidna reproducers + + // Target contract instance (you may need to adjust this) + ReplayTest8 Tester = this; + + modifier setup() override { + _; + } + + function setUp() public { + // Deploy protocol contracts + _setUp(); + + /// @dev fixes the actor to the first user + actor = userToActor[USER1]; + + vm.warp(101007); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // REPLAY TESTS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function test_replay_8() public { + _setUpActor(USER1); + _delay(35); + Tester.refreshPremium(3, 0); + _delay(1); + Tester.refreshPremium(-3, 0); + _checkAllHubInvariants(); + } + + function test_replay_8_2() public { + _setUpActor(USER1); + Tester.add(1, 0); + Tester.draw(1, 0); + _delay(5); + Tester.restore(1, 0, 0, 0); + _checkAllHubInvariants(); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Fast forward the time and set up an actor, + /// @dev Use for ECHIDNA call-traces + function _delay(uint256 _seconds) internal { + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up an actor + function _setUpActor(address _origin) internal { + actor = userToActor[_origin]; + } + + /// @notice Set up an actor and fast forward the time + /// @dev Use for ECHIDNA call-traces + function _setUpActorAndDelay(address _origin, uint256 _seconds) internal { + actor = userToActor[_origin]; + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up a specific block and actor + function _setUpBlockAndActor(uint256 _block, address _user) internal { + vm.roll(_block); + actor = userToActor[_user]; + } + + /// @notice Set up a specific timestamp and actor + function _setUpTimestampAndActor(uint256 _timestamp, address _user) internal { + vm.warp(_timestamp); + actor = userToActor[_user]; + } +} diff --git a/invariants/hub-suite/replays/ReplayTest_9.t.sol b/invariants/hub-suite/replays/ReplayTest_9.t.sol new file mode 100644 index 000000000..92c36ecc3 --- /dev/null +++ b/invariants/hub-suite/replays/ReplayTest_9.t.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +// Libraries +import 'forge-std/Test.sol'; +import 'forge-std/console.sol'; + +// Contracts +import {Invariants} from '../Invariants.t.sol'; +import {Setup} from '../Setup.t.sol'; + +// Utils +import {Actor} from '../../shared/utils/Actor.sol'; + +contract ReplayTest9 is Invariants, Setup { + // Generated from Echidna reproducers + + // Target contract instance (you may need to adjust this) + ReplayTest9 Tester = this; + + modifier setup() override { + _; + } + + function setUp() public { + // Deploy protocol contracts + _setUp(); + + /// @dev fixes the actor to the first user + actor = userToActor[USER1]; + + vm.warp(101007); + } + + function test_replay_9_sum_of_balances() public { + Tester.add(1, 2); + Tester.refreshPremium(445022, 2); + _delay(18880); + _delay(23650); + Tester.donateUnderlyingToHub(0, 0); + _checkAllHubInvariants(); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Fast forward the time and set up an actor, + /// @dev Use for ECHIDNA call-traces + function _delay(uint256 _seconds) internal { + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up an actor + function _setUpActor(address _origin) internal { + actor = userToActor[_origin]; + } + + /// @notice Set up an actor and fast forward the time + /// @dev Use for ECHIDNA call-traces + function _setUpActorAndDelay(address _origin, uint256 _seconds) internal { + actor = userToActor[_origin]; + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up a specific block and actor + function _setUpBlockAndActor(uint256 _block, address _user) internal { + vm.roll(_block); + actor = userToActor[_user]; + } + + /// @notice Set up a specific timestamp and actor + function _setUpTimestampAndActor(uint256 _timestamp, address _user) internal { + vm.warp(_timestamp); + actor = userToActor[_user]; + } +} diff --git a/invariants/hub-suite/specs/HubInvariantsSpec.t.sol b/invariants/hub-suite/specs/HubInvariantsSpec.t.sol new file mode 100644 index 000000000..8176af86b --- /dev/null +++ b/invariants/hub-suite/specs/HubInvariantsSpec.t.sol @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +/// @title HubInvariantsSpec +/// @notice Invariants specification for the hub +/// @dev Contains pseudo code and description for the invariant properties in the hub. +/// This is the canonical source for all hub invariant strings. +/// Protocol-suite imports these via inheritance. +abstract contract HubInvariantsSpec { + /*///////////////////////////////////////////////////////////////////////////////////////////// + // PROPERTY TYPES // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// - INVARIANTS (INV): + /// - Properties that should always hold true in the system. + /// - Implemented in the /invariants folder. + + /////////////////////////////////////////////////////////////////////////////////////////////*/ + + /////////////////////////////////////////////////////////////////////////////////////////////// + // ACCOUNTING // + /////////////////////////////////////////////////////////////////////////////////////////////// + + string constant INV_HUB_A = 'INV_HUB_A: If hub assets = 0 => shares 0'; + + string constant INV_HUB_B = + 'INV_HUB_B: Sum of spoke debts on a single asset must be greater or equal than the total debt of the asset'; + + string constant INV_HUB_C = + 'INV_HUB_C: Sum of [baseDrawnShares/premiumDrawnShares/premiumOffsetRay] of individual (spoke/user) should match the corresponding value of the asset on the Hub'; + + string constant INV_HUB_G = + 'INV_HUB_G: totalAddedAssets = sum of addedAssets of all registered spokes (including present & past treasury spoke) with a tolerance of SPOKE_COUNT'; + + string constant INV_HUB_H = + 'INV_HUB_H: totalAddedShares = sum of addedShares of all registered spokes'; + + string constant INV_HUB_O = + 'INV_HUB_O: sum of deficitRay across spokes for a given asset == total asset deficitRay'; + + string constant INV_HUB_P = + 'INV_HUB_P: Premium offset should not exceed premium shares * drawnIndex'; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // SOLVENCY // + /////////////////////////////////////////////////////////////////////////////////////////////// + + string constant INV_HUB_E_1 = + 'INV_HUB_E: total assets is equal to or greater than the supplied amount without taking into account the virtual assets and shares up to the burn interest to virtual shares'; + + string constant INV_HUB_E_2 = + 'INV_HUB_E: total assets is equal to the supplied amount when taking into account the virtual assets and shares'; + + string constant INV_HUB_F = + 'INV_HUB_F: hub.getTotalSuppliedAssets = totalAssets() = availableLiquidity + (totalDebtRay + deficitRay).fromRayUp + swept'; + + string constant INV_HUB_I = + 'INV_HUB_I: asset.underlying.balanceOf(hub) + asset.swept >= asset.liquidity'; + + string constant INV_HUB_K = + 'INV_HUB_K: Asset.irStrategy should never be address(0) for any (currently/previously) registered asset'; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // MONOTONICITY // + /////////////////////////////////////////////////////////////////////////////////////////////// + + string constant INV_HUB_Q = + 'INV_HUB_Q: Drawn index must be monotonically non-decreasing across invariant checks'; + + string constant INV_HUB_R = + 'INV_HUB_R: Supply share price (addedAssets/addedShares) must be monotonically non-decreasing across invariant checks'; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // ERC4626 // + /////////////////////////////////////////////////////////////////////////////////////////////// + + string constant INV_HUB_ERC4626_A = + 'INV_HUB_ERC4626_A: Spoke cannot have non-zero assets and zero shares in add side without any premium'; + + string constant INV_HUB_ERC4626_B = + 'INV_HUB_ERC4626_B: Spoke cannot have non-zero assets and zero shares in draw side'; + + string constant INV_HUB_ERC4626_C = + 'INV_HUB_ERC4626_C: Asset cannot have non-zero assets and zero shares in add side without any premium'; + + string constant INV_HUB_ERC4626_D = + 'INV_HUB_ERC4626_D: Asset cannot have non-zero assets and zero shares in draw side'; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // ERC4626 ROUNDTRIP // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @dev ERC4626: redeem(deposit(a)) <= a + string constant INV_HUB_ERC4626_RT_A = + 'INV_HUB_ERC4626_RT_A: previewRemoveByShares(previewAddByAssets(a)) <= a'; + + /// @dev ERC4626: s = deposit(a), s' = withdraw(a), s' >= s + string constant INV_HUB_ERC4626_RT_B = + 'INV_HUB_ERC4626_RT_B: previewRemoveByAssets(a) >= previewAddByAssets(a)'; + + /// @dev ERC4626: deposit(redeem(s)) <= s + string constant INV_HUB_ERC4626_RT_C = + 'INV_HUB_ERC4626_RT_C: previewAddByAssets(previewRemoveByShares(s)) <= s'; + + /// @dev ERC4626: a = redeem(s), a' = mint(s), a' >= a + string constant INV_HUB_ERC4626_RT_D = + 'INV_HUB_ERC4626_RT_D: previewAddByShares(s) >= previewRemoveByShares(s)'; + + /// @dev ERC4626: withdraw(mint(s)) >= s + string constant INV_HUB_ERC4626_RT_E = + 'INV_HUB_ERC4626_RT_E: previewRemoveByAssets(previewAddByShares(s)) >= s'; + + /// @dev ERC4626: a = mint(s), a' = redeem(s), a' <= a + string constant INV_HUB_ERC4626_RT_F = + 'INV_HUB_ERC4626_RT_F: previewRemoveByShares(s) <= previewAddByShares(s)'; + + /// @dev ERC4626: mint(withdraw(a)) >= a + string constant INV_HUB_ERC4626_RT_G = + 'INV_HUB_ERC4626_RT_G: previewAddByShares(previewRemoveByAssets(a)) >= a'; + + /// @dev ERC4626: s = withdraw(a), s' = deposit(a), s' <= s + string constant INV_HUB_ERC4626_RT_H = + 'INV_HUB_ERC4626_RT_H: previewAddByAssets(a) <= previewRemoveByAssets(a)'; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // AVAILABILITY // + /////////////////////////////////////////////////////////////////////////////////////////////// + + string constant INV_HUB_AVAILABILITY_A = 'INV_HUB_AVAILABILITY_A: getAddedAssets must not revert'; + + string constant INV_HUB_AVAILABILITY_B = 'INV_HUB_AVAILABILITY_B: getAssetOwed must not revert'; + + string constant INV_HUB_AVAILABILITY_C = + 'INV_HUB_AVAILABILITY_C: getAssetTotalOwed must not revert'; + + string constant INV_HUB_AVAILABILITY_D = + 'INV_HUB_AVAILABILITY_D: getAssetPremiumRay must not revert'; + + string constant INV_HUB_AVAILABILITY_E = + 'INV_HUB_AVAILABILITY_E: getAssetAccruedFees must not revert'; + + string constant INV_HUB_AVAILABILITY_F = + 'INV_HUB_AVAILABILITY_F: getSpokeAddedAssets must not revert'; + + string constant INV_HUB_AVAILABILITY_G = 'INV_HUB_AVAILABILITY_G: getSpokeOwed must not revert'; + + string constant INV_HUB_AVAILABILITY_H = + 'INV_HUB_AVAILABILITY_H: getSpokeTotalOwed must not revert'; + + string constant INV_HUB_AVAILABILITY_I = + 'INV_HUB_AVAILABILITY_I: getSpokePremiumRay must not revert'; +} diff --git a/invariants/hub-suite/specs/HubPostconditionsSpec.t.sol b/invariants/hub-suite/specs/HubPostconditionsSpec.t.sol new file mode 100644 index 000000000..c5aff7698 --- /dev/null +++ b/invariants/hub-suite/specs/HubPostconditionsSpec.t.sol @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +/// @title HubPostconditionsSpec +/// @notice Postconditions specification for the hub +/// @dev Contains pseudo code and description for the postcondition properties in the hub. +/// This is the canonical source for all hub postcondition strings. +/// Protocol-suite imports these via inheritance. +abstract contract HubPostconditionsSpec { + /*///////////////////////////////////////////////////////////////////////////////////////////// + // PROPERTY TYPES // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// - POSTCONDITIONS: + /// - Properties that should hold true after an action is executed. + /// - Implemented in the /hooks and /handlers folders. + /// - There are two types of POSTCONDITIONS: + /// - GLOBAL POSTCONDITIONS (GPOST): + /// - Properties that should always hold true after any action is executed. + /// - Checked in the `_checkPostConditions` function within the HookAggregator contract. + /// - HANDLER-SPECIFIC POSTCONDITIONS (HSPOST): + /// - Properties that should hold true after a specific action is executed in a specific context. + /// - Implemented within each handler function, under the HANDLER-SPECIFIC POSTCONDITIONS section. + + /////////////////////////////////////////////////////////////////////////////////////////////*/ + + /////////////////////////////////////////////////////////////////////////////////////////////// + // MONOTONICITY // + /////////////////////////////////////////////////////////////////////////////////////////////// + + string constant GPOST_HUB_A = + 'GPOST_HUB_A: Drawn index cannot decrease (remains constant or increases). If no time passes, it stays constant. only increases due to interest accumulation'; + + string constant GPOST_HUB_B = + "GPOST_HUB_B: Add exchange rate (total assets / total shares) cannot decrease (remains constant or increases). If no time passes, it stays constant. it increases due to interest accumulation, premium debt settlement and donations (from actions' rounding)."; + + string constant GPOST_HUB_D = + 'GPOST_HUB_D: lastUpdateTimestamp must be <= block.timestamp after any action (timestamps cannot be in the future).'; + + string constant GPOST_HUB_G = + 'GPOST_HUB_G: lastUpdateTimestamp is monotonic non-decreasing across actions (time does not go backwards)'; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // ACCOUNTING // + /////////////////////////////////////////////////////////////////////////////////////////////// + + string constant GPOST_HUB_C = + 'GPOST_HUB_C: Borrow rate should always match the calculated amount right after any hub non-view operation in the same block.'; + + string constant HSPOST_HUB_M = + 'HSPOST_HUB_M: refreshPremium cannot change total premium debt (only redistribution)'; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // CAPS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + string constant GPOST_HUB_E = + 'GPOST_HUB_E: if addedAssets for a spoke & assetId increase, addedAssets <= addCap * precision (when cap != MAX)'; + + string constant GPOST_HUB_F = + 'GPOST_HUB_F: if owed for a spoke & assetId increase, owed + deficit <= drawCap * precision (when cap != MAX)'; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // ERC4626 // + /////////////////////////////////////////////////////////////////////////////////////////////// + + // Add + string constant HSPOST_HUB_ERC4626_ADD_A = + 'HSPOST_HUB_ERC4626_ADD_A: After add, spoke addedAssets must increase by at most addedAmount'; + + string constant HSPOST_HUB_ERC4626_ADD_B = + 'HSPOST_HUB_ERC4626_ADD_B: After add, spoke addedShares must increase by addedSharesAmount'; + + string constant HSPOST_HUB_ERC4626_ADD_C = + 'HSPOST_HUB_ERC4626_ADD_C: previewAddedShares must be less than or equal to the added shares after the action'; + + // Remove + string constant HSPOST_HUB_ERC4626_REMOVE_A = + 'HSPOST_HUB_ERC4626_REMOVE_A: After remove, spoke addedAssets must decrease by at least removedAmount'; + + string constant HSPOST_HUB_ERC4626_REMOVE_B = + 'HSPOST_HUB_ERC4626_REMOVE_B: After remove, spoke addedShares must decrease by removedSharesAmount'; + + string constant HSPOST_HUB_ERC4626_REMOVE_C = + 'HSPOST_HUB_ERC4626_REMOVE_C: previewRemovedShares must be greater than or equal to the removed shares after the action'; + + // Draw + string constant HSPOST_HUB_ERC4626_DRAW_A = + 'HSPOST_HUB_ERC4626_DRAW_A: After draw, spoke drawnAssets must increase by at least drawnAmount'; + + string constant HSPOST_HUB_ERC4626_DRAW_B = + 'HSPOST_HUB_ERC4626_DRAW_B: After draw, spoke drawnShares must increase by drawnSharesAmount'; + + string constant HSPOST_HUB_ERC4626_DRAW_C = + 'HSPOST_HUB_ERC4626_DRAW_C: previewDrawnShares must be greater than or equal to the drawn shares after the action'; + + // Restore + string constant HSPOST_HUB_ERC4626_RESTORE_A = + 'HSPOST_HUB_ERC4626_RESTORE_A: After restore, spoke drawnAssets must increase by at most drawnAmount'; + + string constant HSPOST_HUB_ERC4626_RESTORE_B = + 'HSPOST_HUB_ERC4626_RESTORE_B: After restore, spoke drawnShares must decrease by restoredSharesAmount'; + + string constant HSPOST_HUB_ERC4626_RESTORE_C = + 'HSPOST_HUB_ERC4626_RESTORE_C: previewRestoredShares must be less than or equal to the restored shares after the action'; +} diff --git a/invariants/hub-suite/utils/StdAsserts.sol b/invariants/hub-suite/utils/StdAsserts.sol new file mode 100644 index 000000000..0ac78b864 --- /dev/null +++ b/invariants/hub-suite/utils/StdAsserts.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +/// @notice Standard assertions for the test suite +abstract contract StdAsserts { + /////////////////////////////////////////////////////////////////////////////////////////////// + // ASSERTION HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Assert that a value is true + function assertTrue(bool condition, string memory errorMessage) internal pure { + require(condition, errorMessage); + } + + /// @notice Assert that a value is false + function assertFalse(bool condition, string memory errorMessage) internal pure { + require(!condition, errorMessage); + } + + /// @notice Assert two values are equal + function assertEqual(uint256 a, uint256 b, string memory errorMessage) internal pure { + require(a == b, errorMessage); + } + + /// @notice Assert a is less than or equal to b + function assertLe(uint256 a, uint256 b, string memory errorMessage) internal pure { + require(a <= b, errorMessage); + } + + /// @notice Assert a is greater than or equal to b + function assertGe(uint256 a, uint256 b, string memory errorMessage) internal pure { + require(a >= b, errorMessage); + } + + /// @notice Assert a is less than b + function assertLt(uint256 a, uint256 b, string memory errorMessage) internal pure { + require(a < b, errorMessage); + } + + /// @notice Assert a is greater than b + function assertGt(uint256 a, uint256 b, string memory errorMessage) internal pure { + require(a > b, errorMessage); + } + + /// @notice Assert two addresses are equal + function assertEq(address a, address b, string memory errorMessage) internal pure { + require(a == b, errorMessage); + } +} diff --git a/invariants/protocol-suite/HandlerAggregator.t.sol b/invariants/protocol-suite/HandlerAggregator.t.sol new file mode 100644 index 000000000..bc2d40bf0 --- /dev/null +++ b/invariants/protocol-suite/HandlerAggregator.t.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +// Handler contracts +import {SpokeHandler} from './handlers/spoke/SpokeHandler.t.sol'; +import {TreasurySpokeHandler} from './handlers/spoke/TreasurySpokeHandler.t.sol'; +import {SpokeConfiguratorHandler} from './handlers/spoke/SpokeConfiguratorHandler.t.sol'; +import {HubConfiguratorHandler} from './handlers/hub/HubConfiguratorHandler.t.sol'; +import {HubAdminHandler} from './handlers/hub/HubAdminHandler.t.sol'; + +// Simulator contracts +import {PriceFeedSimulatorHandler} from './handlers/simulators/PriceFeedSimulatorHandler.t.sol'; +import {DonationAttackHandler} from './handlers/simulators/DonationAttackHandler.t.sol'; + +/// @notice Helper contract to aggregate all handler contracts, inherited in BaseInvariants +abstract contract HandlerAggregator is + SpokeHandler, // Main handlers + TreasurySpokeHandler, + HubConfiguratorHandler, // Configurators + SpokeConfiguratorHandler, + HubAdminHandler, + PriceFeedSimulatorHandler, // Simulators + DonationAttackHandler +{ + /// @notice Helper function in case any handler requires additional setup + function _setUpHandlers() internal {} +} diff --git a/invariants/protocol-suite/Invariants.t.sol b/invariants/protocol-suite/Invariants.t.sol new file mode 100644 index 000000000..501b4f440 --- /dev/null +++ b/invariants/protocol-suite/Invariants.t.sol @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import {EnumerableSet} from 'src/dependencies/openzeppelin/EnumerableSet.sol'; + +// Interfaces +import {ITreasurySpoke} from 'src/spoke/interfaces/ITreasurySpoke.sol'; +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; +import {IHub} from 'src/hub/interfaces/IHub.sol'; + +// Hub invariant assertions (imported from hub-suite) +import {HubInvariantAssertions} from '../hub-suite/invariants/HubInvariantAssertions.t.sol'; + +// Spoke invariants (protocol-suite) +import {SpokeInvariants} from './invariants/SpokeInvariants.t.sol'; + +/// @title Invariants +/// @notice Wrappers for the protocol invariants implemented in each invariants contract +/// @dev recognized by Echidna when property mode is activated +/// @dev Inherits HubInvariantAssertions, SpokeInvariants +abstract contract Invariants is SpokeInvariants, HubInvariantAssertions { + using EnumerableSet for EnumerableSet.AddressSet; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // VIRTUAL OVERRIDES // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @dev Returns allSpokes for the given hub (includes treasury spokes). + function _getSpokesForAsset(IHub, uint256) internal view override returns (address[] memory) { + return allSpokes; + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HUB ACCOUNTING // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function invariant_INV_HUB_ACCOUNTING() public returns (bool) { + for (uint256 i; i < hubs.length(); i++) { + IHub hub = IHub(hubs.at(i)); + uint256 assetCount = hub.getAssetCount(); + for (uint256 j; j < assetCount; j++) { + assert_INV_HUB_A(hub, j); + assert_INV_HUB_B(hub, j); + assert_INV_HUB_C(hub, j); + assert_INV_HUB_GH(hub, j); + assert_INV_HUB_O(hub, j); + assert_INV_HUB_P(hub, j); + } + } + return true; + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HUB SOLVENCY // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function invariant_INV_HUB_SOLVENCY() public returns (bool) { + for (uint256 i; i < hubs.length(); i++) { + IHub hub = IHub(hubs.at(i)); + uint256 assetCount = hub.getAssetCount(); + for (uint256 j; j < assetCount; j++) { + assert_INV_HUB_E(hub, j); + assert_INV_HUB_F(hub, j); + assert_INV_HUB_I(hub, j); + assert_INV_HUB_K(hub, j); + } + } + return true; + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HUB MONOTONICITY // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function invariant_INV_HUB_MONOTONICITY() public returns (bool) { + for (uint256 i; i < hubs.length(); i++) { + IHub hub = IHub(hubs.at(i)); + uint256 assetCount = hub.getAssetCount(); + for (uint256 j; j < assetCount; j++) { + assert_INV_HUB_Q(hub, j); + assert_INV_HUB_R(hub, j); + } + } + return true; + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // SPOKE SYNC // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function invariant_INV_SP_SYNC() public returns (bool) { + // Applied per user-facing spoke + for (uint256 i; i < spokes.length(); i++) { + ISpoke spoke = ISpoke(spokes.at(i)); + uint256 reserveCount = spoke.getReserveCount(); + for (uint256 j; j < reserveCount; j++) { + assert_INV_SP_A(spoke, j); + } + } + + // Applied per treasury spoke (only hub-sync invariant applies; + // user-level invariants don't apply since TreasurySpoke has no per-user positions) + for (uint256 i; i < treasurySpokes.length(); i++) { + ISpoke spoke = ISpoke(treasurySpokes.at(i)); + // reserveId == assetId for treasury spoke + uint256 reserveCount = IHub(address(ITreasurySpoke(address(spoke)).HUB())).getAssetCount(); + for (uint256 j; j < reserveCount; j++) { + assert_INV_SP_A(spoke, j); + } + } + + return true; + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // SPOKE ACCOUNTING // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function invariant_INV_SP_ACCOUNTING() public returns (bool) { + for (uint256 i; i < spokes.length(); i++) { + ISpoke spoke = ISpoke(spokes.at(i)); + uint256 reserveCount = spoke.getReserveCount(); + for (uint256 j; j < reserveCount; j++) { + assert_INV_SP_C(spoke, j); + assert_INV_SP_E(spoke, j); + assert_INV_SP_F(spoke, j); + for (uint256 k; k < actors.length(); k++) { + assert_INV_SP_B(spoke, j, actors.at(k)); + } + } + } + return true; + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // SPOKE RISK // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function invariant_INV_SP_RISK() public returns (bool) { + for (uint256 i; i < spokes.length(); i++) { + ISpoke spoke = ISpoke(spokes.at(i)); + + // Applied per actor on the spoke + for (uint256 j; j < actors.length(); j++) { + assert_INV_SP_D(spoke, actors.at(j)); + } + + // Applied per reserve per actor of the spoke + uint256 reserveCount = spoke.getReserveCount(); + for (uint256 j; j < reserveCount; j++) { + for (uint256 k; k < actors.length(); k++) { + assert_INV_SP_H(spoke, j, actors.at(k)); + assert_INV_SP_I(spoke, j, actors.at(k)); + } + } + } + return true; + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // REPLAY HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function _checkAllHubInvariants() internal { + assertTrue(invariant_INV_HUB_ACCOUNTING()); + assertTrue(invariant_INV_HUB_SOLVENCY()); + assertTrue(invariant_INV_HUB_MONOTONICITY()); + } + + function _checkAllSpokeInvariants() internal { + assertTrue(invariant_INV_SP_SYNC()); + assertTrue(invariant_INV_SP_ACCOUNTING()); + assertTrue(invariant_INV_SP_RISK()); + } +} diff --git a/invariants/protocol-suite/README.md b/invariants/protocol-suite/README.md new file mode 100644 index 000000000..57fa948b9 --- /dev/null +++ b/invariants/protocol-suite/README.md @@ -0,0 +1,133 @@ +# Fuzzing & Invariant Testing Suite + +A comprehensive handler-based invariant testing suite for the Aave v4 protocol. This suite performs deep stateful fuzzing across multiple hubs and spokes, validating critical system properties through automated property checking and postcondition verification. + +## Overview + +The suite tests a complex multi-hub, multi-spoke deployment with: + +- **2 Hubs** with distinct interest rate strategies and asset configurations +- **2 Spokes** with varying risk parameters (conservative vs. aggressive) +- **Cross-hub liquidity flows** simulating bridge mechanics with different capacity caps +- **Multiple actors** executing concurrent operations (supply, borrow, repay, liquidations) + +All protocol actions are monitored by hooks that snapshot state and verify postconditions after each transaction, enabling detection of invariant violations and edge cases that could lead to protocol insolvency or user fund loss. + +## Tooling + +Compatible with industry-standard fuzzing tools: + +- **Echidna** - battle tested haskell based property-based fuzzer +- **Medusa** - parallelized, coverage-guided, smart contract fuzzing, powered by go-ethereum +- **Foundry** - native invariant testing framework + +## Architecture + +### Dependencies + +Hub invariant assertions and spec strings are imported from hub-suite — not duplicated. Spoke-specific code is self-contained. + +``` +protocol-suite → hub-suite → shared/ +``` + +| Imported from hub-suite | Local file | +| ------------------------ | -------------------------------- | +| `HubInvariantsSpec` | `specs/InvariantsSpec.t.sol` | +| `HubPostconditionsSpec` | `specs/PostconditionsSpec.t.sol` | +| `HubInvariantAssertions` | `Invariants.t.sol` | + +### Core Components + +**Setup Layer** (`Setup.t.sol`, `base/`) + +- Deploys 2-hub, 2-spoke architecture with 2 treasury spokes +- Configures distinct collateral factors, liquidation parameters, and interest rate curves +- Initializes multiple actors with protocol permissions + +**Spec Layer** (`specs/`) + +- `InvariantsSpec` – inherits `HubInvariantsSpec` from hub-suite, adds spoke-specific strings (`INV_SP_*`) +- `PostconditionsSpec` – inherits `HubPostconditionsSpec` from hub-suite, adds spoke-specific strings (`GPOST_SP_*`, `HSPOST_SP_*`) + +**Handler Layer** (`handlers/`) + +- `SpokeHandler` – user operations (supply, borrow, repay, withdraw, liquidations) +- `TreasurySpokeHandler` – fee collection and distribution +- `HubConfiguratorHandler`, `SpokeConfiguratorHandler` – admin operations +- `PriceFeedSimulatorHandler`, `DonationAttackHandler` – simulation handlers + +**Invariant Layer** (`invariants/`) + +- Hub invariant assertions imported from hub-suite via `HubInvariantAssertions` (INV_HUB_A through R) +- `SpokeInvariants` – spoke-specific invariant assertions (INV_SP_A through I) +- Stateful hub invariants Q and R imported from hub-suite's `HubInvariantAssertions` + +**Verification Layer** (`hooks/`) + +- Before/after hooks with state snapshots +- Global and handler-specific postcondition assertions +- Hub and spoke postconditions + +**Replay Layer** (`replays/`) + +- Minimal reproduction tests for discovered violations +- Facilitates debugging and regression prevention + +## How It Works + +1. **Fuzzer** generates random inputs and selects handler functions +2. **Handlers** execute protocol actions through actor proxies (respects roles and permissions) +3. **Hooks** capture snapshots of relevant state variables for analysis +4. **Postconditions** validate expected outcomes and state transitions (e.g., "drawn rate matches calculated rate after hub non-view operations") +5. **Invariants** continuously checked across all protocol states + +## Quick Start + +```bash +# Run full fuzzing campaign with Medusa +make medusa + +# Run with Echidna in assertion mode +make echidna-assert + +# Generate replay tests from Echidna corpus +make runes-echidna + +# Generate replay tests from Medusa corpus +make runes-medusa +``` + +## Advanced Usage + +**Echidna Modes:** + +```bash +make echidna # Property mode (boolean invariants) +make echidna-assert # Assertion mode (require/assert violations) +make echidna-explore # Exploration mode (maximize coverage) +``` + +**Foundry:** + +```bash +make foundry-invariants # Native Foundry invariant runner +``` + +**Replay Specific Failure:** + +```bash +forge test --mc ReplayTest_1 -vvv +``` + +## Key Features + +- Multi-hub, multi-spoke testing for cross-protocol interactions +- Hub invariant logic imported from hub-suite (single source of truth, no duplication) +- Comprehensive postcondition checking after every state transition +- Actor-based modeling for realistic multi-user scenarios +- Admin operation fuzzing (config updates, parameter changes) + +--- + +**Note:** This suite complements unit tests by exploring unbounded state spaces and adversarial scenarios that are difficult to anticipate manually. diff --git a/invariants/protocol-suite/Setup.t.sol b/invariants/protocol-suite/Setup.t.sol new file mode 100644 index 000000000..5288ef571 --- /dev/null +++ b/invariants/protocol-suite/Setup.t.sol @@ -0,0 +1,676 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +// Libraries +import {EnumerableSet} from 'src/dependencies/openzeppelin/EnumerableSet.sol'; +import {CREATE3} from '../shared/utils/CREATE3.sol'; +import {ActorsUtils} from '../shared/utils/ActorsUtils.sol'; +import {Constants} from 'tests/Constants.sol'; +import {Roles} from 'src/libraries/types/Roles.sol'; + +// Interfaces +import {IAaveOracle} from 'src/spoke/interfaces/IAaveOracle.sol'; +import {ISpokeInstance} from 'tests/mocks/ISpokeInstance.sol'; +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; +import {ITreasurySpoke} from 'src/spoke/interfaces/ITreasurySpoke.sol'; +import {IHub} from 'src/hub/interfaces/IHub.sol'; + +// Test Contracts +import {Actor} from '../shared/utils/Actor.sol'; +import {TestnetERC20} from 'tests/mocks/TestnetERC20.sol'; +import {MockPriceFeedSimulator} from '../shared/mocks/MockPriceFeedSimulator.sol'; + +// Contracts +import {BaseTest} from './base/BaseTest.t.sol'; +import {DeployUtils} from 'tests/DeployUtils.sol'; +import { + AssetInterestRateStrategy, + IAssetInterestRateStrategy +} from 'src/hub/AssetInterestRateStrategy.sol'; +import {AccessManagerEnumerable} from 'src/access/AccessManagerEnumerable.sol'; +import {TreasurySpoke} from 'src/spoke/TreasurySpoke.sol'; +import {AaveOracle} from 'src/spoke/AaveOracle.sol'; +import {HubConfigurator, IHubConfigurator} from 'src/hub/HubConfigurator.sol'; +import {SpokeConfigurator, ISpokeConfigurator} from 'src/spoke/SpokeConfigurator.sol'; +import {Hub} from 'src/hub/Hub.sol'; +import {SpokeInstance} from 'src/spoke/instances/SpokeInstance.sol'; +import {LiquidationLogic} from 'src/spoke/libraries/LiquidationLogic.sol'; + +/// @notice Setup contract for the invariant test Suite, inherited by Tester +contract Setup is BaseTest { + using EnumerableSet for EnumerableSet.AddressSet; + using ActorsUtils for *; + + /// @notice Number of actors to deploy + function _setUp() internal { + // Deploy the suite assets + _deployAssets(); + + // Deploy protocol contracts and protocol actors + _deployProtocolCore(); + + // Configure the token list on the protocol + _configureTokenList(); + + // Deploy actors + _setUpActors(); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // ASSETS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Deploy the suite assets + function _deployAssets() internal { + usdc = new TestnetERC20('USDC', 'USDC', 6); + weth = new TestnetERC20('WETH', 'WETH', 18); + + baseAssets.push(AssetInfo({underlying: address(usdc), decimals: 6})); + baseAssets.push(AssetInfo({underlying: address(weth), decimals: 18})); + + vm.label(address(usdc), 'usdc'); + vm.label(address(weth), 'weth'); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // CORE // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Deploy protocol core contracts + function _deployProtocolCore() internal { + // Access manager + accessManager = new AccessManagerEnumerable(admin); + + // Hub 1 + hub1 = new Hub(address(accessManager)); + irStrategy1 = new AssetInterestRateStrategy(address(hub1)); + hubInfo[address(hub1)] = HubInfo({ + treasurySpoke: address(treasurySpoke1), + irStrategy: address(irStrategy1) + }); + hubs.add(address(hub1)); + + // Hub 2 + hub2 = new Hub(address(accessManager)); + irStrategy2 = new AssetInterestRateStrategy(address(hub2)); + hubInfo[address(hub2)] = HubInfo({ + treasurySpoke: address(treasurySpoke2), + irStrategy: address(irStrategy2) + }); + hubs.add(address(hub2)); + + // Spokes + (spoke1, oracle1) = _deploySpokeWithOracle(admin, address(accessManager), 'Spoke 1 (USD)'); + (spoke2, oracle2) = _deploySpokeWithOracle(admin, address(accessManager), 'Spoke 2 (USD)'); + treasurySpoke1 = ITreasurySpoke(new TreasurySpoke(admin, address(hub1))); + treasurySpoke2 = ITreasurySpoke(new TreasurySpoke(admin, address(hub2))); + allSpokes.push(address(treasurySpoke1)); + allSpokes.push(address(treasurySpoke2)); + treasurySpokes.add(address(treasurySpoke1)); + treasurySpokes.add(address(treasurySpoke2)); + + // Configurators + hubConfigurator = new HubConfigurator(address(accessManager)); + spokeConfigurator = new SpokeConfigurator(address(accessManager)); + _setUpConfiguratorRoles(); + + vm.label(address(accessManager), 'accessManager'); + vm.label(address(hub1), 'hub1'); + vm.label(address(hub2), 'hub2'); + vm.label(address(hubConfigurator), 'hubConfigurator'); + vm.label(address(spokeConfigurator), 'spokeConfigurator'); + vm.label(address(irStrategy1), 'irStrategy1'); + vm.label(address(irStrategy2), 'irStrategy2'); + vm.label(address(spoke1), 'spoke1'); + vm.label(address(spoke2), 'spoke2'); + vm.label(address(treasurySpoke1), 'treasurySpoke1'); + vm.label(address(treasurySpoke2), 'treasurySpoke2'); + vm.label(address(oracle1), 'oracle1'); + vm.label(address(oracle2), 'oracle2'); + } + + /// @notice Deploy a spoke with an oracle + function _deploySpokeWithOracle( + address proxyAdminOwner, + address _accessManager, + string memory _oracleDesc + ) internal returns (ISpoke, IAaveOracle) { + address deployer = _makeAddr('deployer'); + + vm.startPrank(deployer); + IAaveOracle oracle = new AaveOracle(8, _oracleDesc); + + ISpoke spoke = ISpoke( + DeployUtils.proxify( + address(new SpokeInstance(address(oracle), Constants.MAX_ALLOWED_USER_RESERVES_LIMIT)), + proxyAdminOwner, + abi.encodeCall(ISpokeInstance.initialize, (_accessManager)) + ) + ); + + oracle.setSpoke(address(spoke)); + vm.stopPrank(); + + assertEq(spoke.ORACLE(), address(oracle)); + assertEq(oracle.SPOKE(), address(spoke)); + + spokes.add(address(spoke)); + allSpokes.push(address(spoke)); + + return (spoke, oracle); + } + + function _deployMockPriceFeed(ISpoke spoke, uint256 price) internal returns (address) { + AaveOracle oracle = AaveOracle(spoke.ORACLE()); + return address(new MockPriceFeedSimulator(oracle.DECIMALS(), oracle.DESCRIPTION(), price)); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // CONFIGS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function _configureTokenList() internal { + // Configure hubs + _configureHubs(); + + // Configure spokes + _configureSpokes(); + } + + /// @notice Configure the hubs + function _configureHubs() internal { + // HUB 1 + bytes memory encodedIrData1 = abi.encode( + IAssetInterestRateStrategy.InterestRateData({ + optimalUsageRatio: OPTIMAL_USAGE_RATIO_IR1, + baseVariableBorrowRate: BASE_VARIABLE_BORROW_RATE_IR1, + variableRateSlope1: VARIABLE_RATE_SLOPE_1_IR1, + variableRateSlope2: VARIABLE_RATE_SLOPE_2_IR1 + }) + ); + + // Add USDC + hub1UsdcAssetId = hub1.addAsset( + address(usdc), + usdc.decimals(), + address(treasurySpoke1), + address(irStrategy1), + encodedIrData1 + ); + hub1.updateAssetConfig( + hub1UsdcAssetId, + IHub.AssetConfig({ + liquidityFee: 5_00, + feeReceiver: address(treasurySpoke1), + irStrategy: address(irStrategy1), + reinvestmentController: address(this) + }), + new bytes(0) + ); + hubAssetIds[address(hub1)].push(hub1UsdcAssetId); + + // Add WETH + hub1WethAssetId = hub1.addAsset( + address(weth), + weth.decimals(), + address(treasurySpoke1), + address(irStrategy1), + encodedIrData1 + ); + hub1.updateAssetConfig( + hub1WethAssetId, + IHub.AssetConfig({ + liquidityFee: 10_00, + feeReceiver: address(treasurySpoke1), + irStrategy: address(irStrategy1), + reinvestmentController: address(this) + }), + new bytes(0) + ); + hubAssetIds[address(hub1)].push(hub1WethAssetId); + + // HUB 2 + bytes memory encodedIrData2 = abi.encode( + IAssetInterestRateStrategy.InterestRateData({ + optimalUsageRatio: OPTIMAL_USAGE_RATIO_IR2, + baseVariableBorrowRate: BASE_VARIABLE_BORROW_RATE_IR2, + variableRateSlope1: VARIABLE_RATE_SLOPE_1_IR2, + variableRateSlope2: VARIABLE_RATE_SLOPE_2_IR2 + }) + ); + + // Add WETH + hub2WethAssetId = hub2.addAsset( + address(weth), + weth.decimals(), + address(treasurySpoke2), + address(irStrategy2), + encodedIrData2 + ); + hub2.updateAssetConfig( + hub2WethAssetId, + IHub.AssetConfig({ + liquidityFee: 10_00, + feeReceiver: address(treasurySpoke2), + irStrategy: address(irStrategy2), + reinvestmentController: address(this) + }), + new bytes(0) + ); + hubAssetIds[address(hub2)].push(hub2WethAssetId); + + // Add USDC + hub2UsdcAssetId = hub2.addAsset( + address(usdc), + usdc.decimals(), + address(treasurySpoke2), + address(irStrategy2), + encodedIrData2 + ); + hub2.updateAssetConfig( + hub2UsdcAssetId, + IHub.AssetConfig({ + liquidityFee: 5_00, + feeReceiver: address(treasurySpoke2), + irStrategy: address(irStrategy2), + reinvestmentController: address(this) + }), + new bytes(0) + ); + hubAssetIds[address(hub2)].push(hub2UsdcAssetId); + } + + function _configureSpokes() internal { + // Configure spoke liquidation configs + spokeConfigurator.updateLiquidationTargetHealthFactor( + address(spoke1), + TARGET_HEALTH_FACTOR_SPOKE1 + ); + spokeConfigurator.updateLiquidationTargetHealthFactor( + address(spoke2), + TARGET_HEALTH_FACTOR_SPOKE2 + ); + + // Spoke 1 reserve configs + spokeInfo[spoke1].usdc.reserveConfig = ISpoke.ReserveConfig({ + paused: false, + frozen: false, + borrowable: true, + receiveSharesEnabled: true, + collateralRisk: 30_00 + }); + spokeInfo[spoke1].usdc.dynReserveConfig = ISpoke.DynamicReserveConfig({ + collateralFactor: 90_00, + maxLiquidationBonus: 100_00, + liquidationFee: 0 + }); + + spokeInfo[spoke1].weth.reserveConfig = ISpoke.ReserveConfig({ + paused: false, + frozen: false, + borrowable: true, + receiveSharesEnabled: true, + collateralRisk: 20_00 + }); + spokeInfo[spoke1].weth.dynReserveConfig = ISpoke.DynamicReserveConfig({ + collateralFactor: 80_00, + maxLiquidationBonus: 105_00, + liquidationFee: 0 + }); + + // Spoke 2 reserve configs + spokeInfo[spoke2].weth.reserveConfig = ISpoke.ReserveConfig({ + paused: false, + frozen: false, + borrowable: true, + receiveSharesEnabled: true, + collateralRisk: 10_00 + }); + spokeInfo[spoke2].weth.dynReserveConfig = ISpoke.DynamicReserveConfig({ + collateralFactor: 70_00, + maxLiquidationBonus: 105_00, + liquidationFee: 0 + }); + + spokeInfo[spoke2].usdc.reserveConfig = ISpoke.ReserveConfig({ + paused: false, + frozen: false, + borrowable: true, + receiveSharesEnabled: true, + collateralRisk: 15_00 + }); + spokeInfo[spoke2].usdc.dynReserveConfig = ISpoke.DynamicReserveConfig({ + collateralFactor: 80_00, + maxLiquidationBonus: 100_00, + liquidationFee: 0 + }); + + // Deploy price feeds + priceFeeds.push(_deployMockPriceFeed(spoke1, 1e8)); + priceFeeds.push(_deployMockPriceFeed(spoke1, 2000e8)); + + // Add reserves to spoke 1 + spokeInfo[spoke1].usdc.reserveId = spoke1.addReserve( + address(hub1), + hub1UsdcAssetId, + priceFeeds[0], + spokeInfo[spoke1].usdc.reserveConfig, + spokeInfo[spoke1].usdc.dynReserveConfig + ); + spokeInfo[spoke1].usdc2.reserveId = spoke1.addReserve( + address(hub2), + hub2UsdcAssetId, + priceFeeds[0], + spokeInfo[spoke1].usdc.reserveConfig, + spokeInfo[spoke1].usdc.dynReserveConfig + ); + spokeInfo[spoke1].weth.reserveId = spoke1.addReserve( + address(hub1), + hub1WethAssetId, + priceFeeds[1], + spokeInfo[spoke1].weth.reserveConfig, + spokeInfo[spoke1].weth.dynReserveConfig + ); + spokeInfo[spoke1].weth2.reserveId = spoke1.addReserve( + address(hub2), + hub2WethAssetId, + priceFeeds[1], + spokeInfo[spoke1].weth.reserveConfig, + spokeInfo[spoke1].weth.dynReserveConfig + ); + + // Add reserves to spoke 2 + spokeInfo[spoke2].weth.reserveId = spoke2.addReserve( + address(hub1), + hub1WethAssetId, + priceFeeds[1], + spokeInfo[spoke2].weth.reserveConfig, + spokeInfo[spoke2].weth.dynReserveConfig + ); + spokeInfo[spoke2].weth2.reserveId = spoke2.addReserve( + address(hub2), + hub2WethAssetId, + priceFeeds[1], + spokeInfo[spoke2].weth.reserveConfig, + spokeInfo[spoke2].weth.dynReserveConfig + ); + spokeInfo[spoke2].usdc.reserveId = spoke2.addReserve( + address(hub1), + hub1UsdcAssetId, + priceFeeds[0], + spokeInfo[spoke2].usdc.reserveConfig, + spokeInfo[spoke2].usdc.dynReserveConfig + ); + spokeInfo[spoke2].usdc2.reserveId = spoke2.addReserve( + address(hub2), + hub2UsdcAssetId, + priceFeeds[0], + spokeInfo[spoke2].usdc.reserveConfig, + spokeInfo[spoke2].usdc.dynReserveConfig + ); + + // Map ids for spoke 1 + assetIdToReserveId[address(spoke1)][hub1UsdcAssetId] = spokeInfo[spoke1].usdc.reserveId; + assetIdToReserveId[address(spoke1)][hub2UsdcAssetId] = spokeInfo[spoke1].usdc2.reserveId; + assetIdToReserveId[address(spoke1)][hub1WethAssetId] = spokeInfo[spoke1].weth.reserveId; + assetIdToReserveId[address(spoke1)][hub2WethAssetId] = spokeInfo[spoke1].weth2.reserveId; + reserveIdToAssetId[address(spoke1)][spokeInfo[spoke1].usdc.reserveId] = hub1UsdcAssetId; + reserveIdToAssetId[address(spoke1)][spokeInfo[spoke1].usdc2.reserveId] = hub2UsdcAssetId; + reserveIdToAssetId[address(spoke1)][spokeInfo[spoke1].weth.reserveId] = hub1WethAssetId; + reserveIdToAssetId[address(spoke1)][spokeInfo[spoke1].weth2.reserveId] = hub2WethAssetId; + reserveIdToHubAddress[address(spoke1)][spokeInfo[spoke1].usdc.reserveId] = address(hub1); + reserveIdToHubAddress[address(spoke1)][spokeInfo[spoke1].usdc2.reserveId] = address(hub2); + reserveIdToHubAddress[address(spoke1)][spokeInfo[spoke1].weth.reserveId] = address(hub1); + reserveIdToHubAddress[address(spoke1)][spokeInfo[spoke1].weth2.reserveId] = address(hub2); + + // Map ids for spoke 2 + assetIdToReserveId[address(spoke2)][hub1UsdcAssetId] = spokeInfo[spoke2].usdc.reserveId; + assetIdToReserveId[address(spoke2)][hub2UsdcAssetId] = spokeInfo[spoke2].usdc2.reserveId; + assetIdToReserveId[address(spoke2)][hub1WethAssetId] = spokeInfo[spoke2].weth.reserveId; + assetIdToReserveId[address(spoke2)][hub2WethAssetId] = spokeInfo[spoke2].weth2.reserveId; + reserveIdToAssetId[address(spoke2)][spokeInfo[spoke2].usdc.reserveId] = hub1UsdcAssetId; + reserveIdToAssetId[address(spoke2)][spokeInfo[spoke2].usdc2.reserveId] = hub2UsdcAssetId; + reserveIdToAssetId[address(spoke2)][spokeInfo[spoke2].weth.reserveId] = hub1WethAssetId; + reserveIdToAssetId[address(spoke2)][spokeInfo[spoke2].weth2.reserveId] = hub2WethAssetId; + reserveIdToHubAddress[address(spoke2)][spokeInfo[spoke2].usdc.reserveId] = address(hub1); + reserveIdToHubAddress[address(spoke2)][spokeInfo[spoke2].usdc2.reserveId] = address(hub2); + reserveIdToHubAddress[address(spoke2)][spokeInfo[spoke2].weth.reserveId] = address(hub1); + reserveIdToHubAddress[address(spoke2)][spokeInfo[spoke2].weth2.reserveId] = address(hub2); + + // Map ids for treasury spokes (reserveId == assetId for treasury spokes) + reserveIdToAssetId[address(treasurySpoke1)][hub1UsdcAssetId] = hub1UsdcAssetId; + reserveIdToAssetId[address(treasurySpoke1)][hub1WethAssetId] = hub1WethAssetId; + reserveIdToHubAddress[address(treasurySpoke1)][hub1UsdcAssetId] = address(hub1); + reserveIdToHubAddress[address(treasurySpoke1)][hub1WethAssetId] = address(hub1); + reserveIdToAssetId[address(treasurySpoke2)][hub2UsdcAssetId] = hub2UsdcAssetId; + reserveIdToAssetId[address(treasurySpoke2)][hub2WethAssetId] = hub2WethAssetId; + reserveIdToHubAddress[address(treasurySpoke2)][hub2UsdcAssetId] = address(hub2); + reserveIdToHubAddress[address(treasurySpoke2)][hub2WethAssetId] = address(hub2); + + // Add SPOKE 1 assets to hubs + hub1.addSpoke( + hub1UsdcAssetId, + address(spoke1), + IHub.SpokeConfig({ + addCap: Constants.MAX_ALLOWED_SPOKE_CAP, + drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, + riskPremiumThreshold: Constants.MAX_RISK_PREMIUM_THRESHOLD, + active: true, + halted: false + }) + ); + hub2.addSpoke( + hub2UsdcAssetId, + address(spoke1), + IHub.SpokeConfig({ + addCap: (Constants.MAX_ALLOWED_SPOKE_CAP / 10) * 3, + drawCap: (Constants.MAX_ALLOWED_SPOKE_CAP / 10) * 3, + riskPremiumThreshold: Constants.MAX_RISK_PREMIUM_THRESHOLD, + active: true, + halted: false + }) + ); + hub1.addSpoke( + hub1WethAssetId, + address(spoke1), + IHub.SpokeConfig({ + addCap: Constants.MAX_ALLOWED_SPOKE_CAP, + drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, + riskPremiumThreshold: Constants.MAX_RISK_PREMIUM_THRESHOLD, + active: true, + halted: false + }) + ); + hub2.addSpoke( + hub2WethAssetId, + address(spoke1), + IHub.SpokeConfig({ + addCap: (Constants.MAX_ALLOWED_SPOKE_CAP / 10) * 2, + drawCap: (Constants.MAX_ALLOWED_SPOKE_CAP / 10) * 2, + riskPremiumThreshold: Constants.MAX_RISK_PREMIUM_THRESHOLD, + active: true, + halted: false + }) + ); + + // Add SPOKE 2 assets to hubs + hub2.addSpoke( + hub2WethAssetId, + address(spoke2), + IHub.SpokeConfig({ + addCap: Constants.MAX_ALLOWED_SPOKE_CAP, + drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, + riskPremiumThreshold: Constants.MAX_RISK_PREMIUM_THRESHOLD, + active: true, + halted: false + }) + ); + hub1.addSpoke( + hub1WethAssetId, + address(spoke2), + IHub.SpokeConfig({ + addCap: (Constants.MAX_ALLOWED_SPOKE_CAP / 10) * 2, + drawCap: (Constants.MAX_ALLOWED_SPOKE_CAP / 10) * 2, + riskPremiumThreshold: Constants.MAX_RISK_PREMIUM_THRESHOLD, + active: true, + halted: false + }) + ); + hub2.addSpoke( + hub2UsdcAssetId, + address(spoke2), + IHub.SpokeConfig({ + addCap: Constants.MAX_ALLOWED_SPOKE_CAP, + drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, + riskPremiumThreshold: Constants.MAX_RISK_PREMIUM_THRESHOLD, + active: true, + halted: false + }) + ); + hub1.addSpoke( + hub1UsdcAssetId, + address(spoke2), + IHub.SpokeConfig({ + addCap: (Constants.MAX_ALLOWED_SPOKE_CAP / 10) * 3, + drawCap: (Constants.MAX_ALLOWED_SPOKE_CAP / 10) * 3, + riskPremiumThreshold: Constants.MAX_RISK_PREMIUM_THRESHOLD, + active: true, + halted: false + }) + ); + } + + /// @notice Set up roles for the configurators + function _setUpConfiguratorRoles() internal virtual { + // Grant roles to configurators + accessManager.grantRole(Roles.HUB_ADMIN_ROLE, address(hubConfigurator), 0); + accessManager.grantRole(Roles.SPOKE_ADMIN_ROLE, address(spokeConfigurator), 0); + accessManager.grantRole(Roles.HUB_ADMIN_ROLE, address(this), 0); + accessManager.grantRole(Roles.SPOKE_ADMIN_ROLE, address(this), 0); + accessManager.grantRole(Roles.HUB_CONFIGURATOR_ROLE, address(this), 0); + accessManager.grantRole(Roles.SPOKE_CONFIGURATOR_ROLE, address(this), 0); + + // Grant responsibilities to spokes + { + bytes4[] memory selectors = new bytes4[](7); + selectors[0] = ISpoke.updateLiquidationConfig.selector; + selectors[1] = ISpoke.updateReserveConfig.selector; + selectors[2] = ISpoke.updateDynamicReserveConfig.selector; + selectors[3] = ISpoke.addDynamicReserveConfig.selector; + selectors[4] = ISpoke.updatePositionManager.selector; + selectors[5] = ISpoke.updateReservePriceSource.selector; + accessManager.setTargetFunctionRole(address(spoke1), selectors, Roles.SPOKE_ADMIN_ROLE); + accessManager.setTargetFunctionRole(address(spoke2), selectors, Roles.SPOKE_ADMIN_ROLE); + } + + // Grant responsibilities to hubs + { + bytes4[] memory selectors = new bytes4[](3); + selectors[0] = IHub.updateSpokeConfig.selector; + selectors[1] = IHub.setInterestRateData.selector; + selectors[2] = IHub.mintFeeShares.selector; // enables HubAdminHandler to materialize fee shares + accessManager.setTargetFunctionRole(address(hub1), selectors, Roles.HUB_ADMIN_ROLE); + accessManager.setTargetFunctionRole(address(hub2), selectors, Roles.HUB_ADMIN_ROLE); + } + + { + bytes4[] memory selectors = new bytes4[](22); + selectors[0] = IHubConfigurator.updateLiquidityFee.selector; + selectors[1] = IHubConfigurator.updateFeeReceiver.selector; + selectors[2] = IHubConfigurator.updateFeeConfig.selector; + selectors[3] = IHubConfigurator.updateInterestRateStrategy.selector; + selectors[4] = IHubConfigurator.updateReinvestmentController.selector; + selectors[5] = IHubConfigurator.resetAssetCaps.selector; + selectors[6] = IHubConfigurator.deactivateAsset.selector; + selectors[7] = IHubConfigurator.haltAsset.selector; + selectors[8] = IHubConfigurator.addSpoke.selector; + selectors[9] = IHubConfigurator.addSpokeToAssets.selector; + selectors[10] = IHubConfigurator.updateSpokeActive.selector; + selectors[11] = IHubConfigurator.updateSpokeHalted.selector; + selectors[12] = IHubConfigurator.updateSpokeAddCap.selector; + selectors[13] = IHubConfigurator.updateSpokeDrawCap.selector; + selectors[14] = IHubConfigurator.updateSpokeRiskPremiumThreshold.selector; + selectors[15] = IHubConfigurator.updateSpokeCaps.selector; + selectors[16] = IHubConfigurator.deactivateSpoke.selector; + selectors[17] = IHubConfigurator.haltSpoke.selector; + selectors[18] = IHubConfigurator.resetSpokeCaps.selector; + selectors[19] = IHubConfigurator.updateInterestRateData.selector; + selectors[20] = IHubConfigurator.addAsset.selector; + selectors[21] = IHubConfigurator.addAssetWithDecimals.selector; + + accessManager.setTargetFunctionRole( + address(hubConfigurator), + selectors, + Roles.HUB_CONFIGURATOR_ROLE + ); + } + + { + bytes4[] memory selectors = new bytes4[](24); + selectors[0] = ISpokeConfigurator.updateReservePriceSource.selector; + selectors[1] = ISpokeConfigurator.updateLiquidationTargetHealthFactor.selector; + selectors[2] = ISpokeConfigurator.updateHealthFactorForMaxBonus.selector; + selectors[3] = ISpokeConfigurator.updateLiquidationBonusFactor.selector; + selectors[4] = ISpokeConfigurator.updateLiquidationConfig.selector; + selectors[5] = ISpokeConfigurator.addReserve.selector; + selectors[6] = ISpokeConfigurator.updatePaused.selector; + selectors[7] = ISpokeConfigurator.updateFrozen.selector; + selectors[8] = ISpokeConfigurator.updateBorrowable.selector; + selectors[9] = ISpokeConfigurator.updateReceiveSharesEnabled.selector; + selectors[10] = ISpokeConfigurator.updateCollateralRisk.selector; + selectors[11] = ISpokeConfigurator.addCollateralFactor.selector; + selectors[12] = ISpokeConfigurator.updateCollateralFactor.selector; + selectors[13] = ISpokeConfigurator.addMaxLiquidationBonus.selector; + selectors[14] = ISpokeConfigurator.updateMaxLiquidationBonus.selector; + selectors[15] = ISpokeConfigurator.addLiquidationFee.selector; + selectors[16] = ISpokeConfigurator.updateLiquidationFee.selector; + selectors[17] = ISpokeConfigurator.addDynamicReserveConfig.selector; + selectors[18] = ISpokeConfigurator.updateDynamicReserveConfig.selector; + selectors[19] = ISpokeConfigurator.pauseAllReserves.selector; + selectors[20] = ISpokeConfigurator.freezeAllReserves.selector; + selectors[21] = ISpokeConfigurator.pauseReserve.selector; + selectors[22] = ISpokeConfigurator.freezeReserve.selector; + selectors[23] = ISpokeConfigurator.updatePositionManager.selector; + + accessManager.setTargetFunctionRole( + address(spokeConfigurator), + selectors, + Roles.SPOKE_CONFIGURATOR_ROLE + ); + } + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // ACTORS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Deploy protocol actors and initialize their balances + function _setUpActors() internal { + // Initialize the three actors of the fuzzers + address[] memory users = new address[](3); + users[0] = USER1; + users[1] = USER2; + users[2] = USER3; + + address[] memory tokens = new address[](2); + tokens[0] = address(usdc); + tokens[1] = address(weth); + + address[] memory contracts = new address[](4); + contracts[0] = address(hub1); + contracts[1] = address(hub2); + contracts[2] = address(spoke1); + contracts[3] = address(spoke2); + + address[] memory actorAddresses = users.setUpActors(tokens, contracts); + for (uint256 i; i < actorAddresses.length; ++i) { + userToActor[users[i]] = Actor(payable(actorAddresses[i])); + actors.add(actorAddresses[i]); + // set all actors are valid position managers such that they can perform actions + // onBehalfOf after approval using setUserPositionManager handler + spoke1.updatePositionManager(actorAddresses[i], true); + spoke2.updatePositionManager(actorAddresses[i], true); + } + } +} diff --git a/invariants/protocol-suite/SpecAggregator.t.sol b/invariants/protocol-suite/SpecAggregator.t.sol new file mode 100644 index 000000000..27fcf0241 --- /dev/null +++ b/invariants/protocol-suite/SpecAggregator.t.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +// Test Contracts +import {InvariantsSpec} from './specs/InvariantsSpec.t.sol'; +import {PostconditionsSpec} from './specs/PostconditionsSpec.t.sol'; + +/// @title SpecAggregator +/// @notice Helper contract to aggregate all spec contracts, inherited in BaseHooks +/// @dev inherits InvariantsSpec, PostconditionsSpec +abstract contract SpecAggregator is InvariantsSpec, PostconditionsSpec { + /////////////////////////////////////////////////////////////////////////////////////////////// + // PROPERTY TYPES // + /////////////////////////////////////////////////////////////////////////////////////////////// + /// In this invariant testing framework, there are two types of properties: + /// - INVARIANTS (INV): + /// - Properties that should always hold true in the system. + /// - Implemented in the /invariants folder. + /// - POSTCONDITIONS: + /// - Properties that should hold true after an action is executed. + /// - Implemented in the /hooks and /handlers folders. + /// - There are two types of POSTCONDITIONS: + /// - GLOBAL POSTCONDITIONS (GPOST): + /// - Properties that should always hold true after any action is executed. + /// - Checked in the `_checkPostConditions` function within the HookAggregator contract. + /// - HANDLER-SPECIFIC POSTCONDITIONS (HSPOST): + /// - Properties that should hold true after a specific action is executed in a specific context. + /// - Implemented within each handler function, under the HANDLER-SPECIFIC POSTCONDITIONS section. + /// - ERC4626 PROPERTIES: + /// - Properties that should always hold true in the system, which check compliance with the ERC4626 standard. + /// - Implemented across the testing suite as invariants, postconditions and specific custom handlers. +} diff --git a/invariants/protocol-suite/TODO b/invariants/protocol-suite/TODO new file mode 100644 index 000000000..989e2f02c --- /dev/null +++ b/invariants/protocol-suite/TODO @@ -0,0 +1,46 @@ +# Replay Tests to Implement + +# INV_HUB_E +## test_replay_3_add +- Status: FAILING +- Invariant: `INV_HUB_E` +- Error: `hub.getAddedAssets and hub.previewRemoveByShares(hub.getAddedShares(assetId)) should match at any time, should not be off by more than 1 share worth of assets due to division precision loss` +- Details: Left: 8, Right: 6, Max Delta: 1, Actual Delta: 2 +- Context: After supply/borrow with time delay, the addedAssets vs previewRemoveByShares diverges by more than the allowed 1 share precision loss + +## test_replay_5_donateUnderlyingToHub +- Status: FAILING +- Invariant: `INV_HUB_E` +- Error: `hub.getAddedAssets and hub.previewRemoveByShares(hub.getAddedShares(assetId)) should match at any time, should not be off by more than 1 share worth of assets due to division precision loss` +- Details: Left: 4, Right: 0, Max Delta: 1, Actual Delta: 4 + +## test_replay_6_freezeAllReserves +- Status: FAILING +- Invariant: `INV_HUB_E` +- Error: `hub.getAddedAssets and hub.previewRemoveByShares(hub.getAddedShares(assetId)) should match at any time, should not be off by more than 1 share worth of assets due to division precision loss` +- Details: Left: 676, Right: 673, Max Delta: 1, Actual Delta: 3 + +# GPOST_HUB_E +## test_replay_7_repay +- Status: FAILING +- Invariant: `GPOST_HUB_E` +- Error: `if addedAssets for a spoke & assetId increase, addedAssets <= addCap * precision (when cap != MAX)` +- Details: 100000000000000000000000977 > 172000000000000000000 (exceeds cap) +- Context: After repay, addedAssets exceeds the configured add cap +- TODO: check if totalAssets increasing is expected behaviour on repayment due to round up on repay amount, before: 100000000000000000000000976, after: 100000000000000000000000977, 1 wei difference + +# GPOST_SP_LIQ_H +## test_replay_7_updateUserRiskPremium +- Status: FAILING +- Invariant: `GPOST_SP_LIQ_H` +- Error: `Only a supply, repay & liquidationCall can leave an account in an unhealthy state` +- Details: 998420221169036334 < 1000000000000000000 (HF < 1e18) +- Context: After updateUserRiskPremium, user's health factor drops below 1 +- TODO: check if updateUserRiskPremium should enforce any health factor constraints + +# INV_HUB_ERC4626_C +## test_replay_9_eliminateDeficit (hub-suite) +- Status: FAILING +- Invariant: `INV_HUB_ERC4626_C` +- Error: `Asset cannot have non-zero assets and zero shares in add side` +- Context: During refreshPremium operation, asset ends up with non-zero assets but zero shares diff --git a/invariants/protocol-suite/Tester.t.sol b/invariants/protocol-suite/Tester.t.sol new file mode 100644 index 000000000..735a17d43 --- /dev/null +++ b/invariants/protocol-suite/Tester.t.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import {Invariants} from './Invariants.t.sol'; +import {Setup} from './Setup.t.sol'; + +/// @title Tester +/// @notice Entry point for invariant testing, inherits all contracts, invariants & handler +/// @dev Mono contract that contains all the testing logic +contract Tester is Invariants, Setup { + constructor() payable { + // Deploy protocol contracts and protocol actors + setUp(); + } + + /// @dev Foundry compatibility faster setup debugging + function setUp() internal { + // Deploy protocol contracts and protocol actors + _setUp(); + } +} diff --git a/invariants/protocol-suite/TesterFoundry.t.sol b/invariants/protocol-suite/TesterFoundry.t.sol new file mode 100644 index 000000000..85c2bd82e --- /dev/null +++ b/invariants/protocol-suite/TesterFoundry.t.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +// Contracts +import {StdInvariant} from 'forge-std/StdInvariant.sol'; +import {Invariants} from './Invariants.t.sol'; +import {Setup} from './Setup.t.sol'; + +/// @title TesterFoundry +/// @notice Entry point for invariant testing, inherits all contracts, invariants & handler +/// @dev Mono contract that contains all the testing logic +contract TesterFoundry is Invariants, Setup, StdInvariant { + /// @dev Foundry compatibility faster setup debugging + function setUp() public { + // Deploy protocol contracts and protocol actors + _setUp(); + + // Set the target contract + targetContract(address(this)); + + // Exclude target selectors + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = this.checkPostConditions.selector; + + excludeSelector(FuzzSelector({addr: address(this), selectors: selectors})); + + // Set the target senders + targetSender(USER1); + targetSender(USER2); + targetSender(USER3); + } +} diff --git a/invariants/protocol-suite/_config/echidna_config.yaml b/invariants/protocol-suite/_config/echidna_config.yaml new file mode 100644 index 000000000..08155a8ad --- /dev/null +++ b/invariants/protocol-suite/_config/echidna_config.yaml @@ -0,0 +1,63 @@ +#codeSize max code size for deployed contratcs (default 24576, per EIP-170) +codeSize: 224576 + +#whether ot not to use the multi-abi mode of testing +#it’s not working for us, see: https://github.com/crytic/echidna/issues/547 +#multi-abi: true + +#balanceAddr is default balance for addresses +balanceAddr: 0x1000000000000000000000000 +#balanceContract overrides balanceAddr for the contract address (2^128 = ~3e38) +balanceContract: 0x1000000000000000000000000000000000000000000000000 + +#testLimit is the number of test sequences to run +testLimit: 1000000 + +#seqLen defines how many transactions are in a test sequence +seqLen: 30 + +#shrinkLimit determines how much effort is spent shrinking failing sequences +shrinkLimit: 2500 + +#propMaxGas defines gas cost at which a property fails +propMaxGas: 1000000000 + +#testMaxGas is a gas limit; does not cause failure, but terminates sequence +testMaxGas: 1000000000 + +# list of methods to filter +filterFunctions: ["Tester.checkPostConditions()"] +# by default, blacklist methods in filterFunctions +#filterBlacklist: false + +prefix: "invariant_" + +#stopOnFail makes echidna terminate as soon as any property fails and has been shrunk +stopOnFail: false + +#coverage controls coverage guided testing +coverage: true + +# list of file formats to save coverage reports in; default is all possible formats +coverageFormats: ["html"] + +#directory to save the corpus; by default is disabled +corpusDir: "invariants/protocol-suite/_corpus/echidna/default/_data/corpus" +# constants for corpus mutations (for experimentation only) +#mutConsts: [100, 1, 1] + +#remappings +cryticArgs: ["--compile-libraries=(LiquidationLogic,0xf01)"] + +deployContracts: [["0xf01", "LiquidationLogic"]] + +# maximum value to send to payable functions +maxValue: 1e+30 # 100000000000 eth + +#quiet produces (much) less verbose output +quiet: false + +format: "text" + +# concurrent workers +workers: 30 diff --git a/invariants/protocol-suite/_config/echidna_config_ci.yaml b/invariants/protocol-suite/_config/echidna_config_ci.yaml new file mode 100644 index 000000000..35d23ee45 --- /dev/null +++ b/invariants/protocol-suite/_config/echidna_config_ci.yaml @@ -0,0 +1,64 @@ +#codeSize max code size for deployed contratcs (default 24576, per EIP-170) +codeSize: 224576 + +#whether ot not to use the multi-abi mode of testing +#it’s not working for us, see: https://github.com/crytic/echidna/issues/547 +#multi-abi: true + +#balanceAddr is default balance for addresses +balanceAddr: 0x1000000000000000000000000 +#balanceContract overrides balanceAddr for the contract address (2^128 = ~3e38) +balanceContract: 0x1000000000000000000000000000000000000000000000000 + +#testLimit is the number of test sequences to run +testLimit: 10000000 + +#timeout in seconds +timeout: 3600 # 1 hour + +#seqLen defines how many transactions are in a test sequence +seqLen: 300 + +#shrinkLimit determines how much effort is spent shrinking failing sequences +shrinkLimit: 1500 + +#propMaxGas defines gas cost at which a property fails +propMaxGas: 1000000000 + +#testMaxGas is a gas limit; does not cause failure, but terminates sequence +testMaxGas: 1000000000 + +# list of methods to filter +filterFunctions: ["Tester.checkPostConditions()"] +# by default, blacklist methods in filterFunctions +#filterBlacklist: false + +prefix: "invariant_" + +#stopOnFail makes echidna terminate as soon as any property fails and has been shrunk +stopOnFail: false + +#coverage controls coverage guided testing +coverage: true + +# list of file formats to save coverage reports in; default is all possible formats +coverageFormats: ["html"] + +#directory to save the corpus; by default is disabled +corpusDir: "corpus" +# constants for corpus mutations (for experimentation only) +#mutConsts: [100, 1, 1] + +#remappings +cryticArgs: ["--ignore-compile", "--compile-libraries=(LiquidationLogic,0xf01)"] + +deployContracts: [["0xf01", "LiquidationLogic"]] + +# maximum value to send to payable functions +maxValue: 1e+30 # 100000000000 eth + +#quiet produces (much) less verbose output +quiet: false + +# concurrent workers +workers: 10 diff --git a/invariants/protocol-suite/_config/medusa_config_ci.json b/invariants/protocol-suite/_config/medusa_config_ci.json new file mode 100644 index 000000000..5a5d9a629 --- /dev/null +++ b/invariants/protocol-suite/_config/medusa_config_ci.json @@ -0,0 +1,83 @@ +{ + "fuzzing": { + "workers": 10, + "workerResetLimit": 50, + "timeout": 3600, + "testLimit": 0, + "callSequenceLength": 300, + "corpusDirectory": "corpus", + "coverageEnabled": true, + "deploymentOrder": ["Tester"], + "targetContracts": ["Tester"], + "targetContractsBalances": [ + "0xffffffffffffffffffffffffffffffffffffffffffffffffffff" + ], + "predeployedContracts": { + "LiquidationLogic": "0xf01" + }, + "constructorArgs": {}, + "deployerAddress": "0x30000", + "senderAddresses": ["0x10000", "0x20000", "0x30000"], + "blockNumberDelayMax": 60480, + "blockTimestampDelayMax": 604800, + "blockGasLimit": 12500000000, + "transactionGasLimit": 1250000000, + "testing": { + "stopOnFailedTest": true, + "stopOnFailedContractMatching": false, + "stopOnNoTests": true, + "testAllContracts": false, + "traceAll": false, + "assertionTesting": { + "enabled": true, + "testViewMethods": true, + "assertionModes": { + "failOnCompilerInsertedPanic": false, + "failOnAssertion": true, + "failOnArithmeticUnderflow": false, + "failOnDivideByZero": false, + "failOnEnumTypeConversionOutOfBounds": false, + "failOnIncorrectStorageAccess": false, + "failOnPopEmptyArray": false, + "failOnOutOfBoundsArrayAccess": false, + "failOnAllocateTooMuchMemory": false, + "failOnCallUninitializedVariable": false + } + }, + "propertyTesting": { + "enabled": true, + "testPrefixes": ["fuzz_", "invariant_"] + }, + "optimizationTesting": { + "enabled": false, + "testPrefixes": ["optimize_"] + }, + "excludeFunctionSignatures": ["Tester.checkPostConditions()"] + }, + "chainConfig": { + "codeSizeCheckDisabled": true, + "cheatCodes": { + "cheatCodesEnabled": true, + "enableFFI": true + } + } + }, + "compilation": { + "platform": "crytic-compile", + "platformConfig": { + "target": "invariants/protocol-suite/Tester.t.sol", + "solcVersion": "", + "exportDirectory": "", + "args": [ + "--ignore-compile", + "--solc-remaps", + "forge-std/=../../../lib/forge-std/src/", + "--compile-libraries=(LiquidationLogic,0xf01)" + ] + } + }, + "logging": { + "level": "info", + "logDirectory": "" + } +} diff --git a/invariants/protocol-suite/base/BaseHandler.t.sol b/invariants/protocol-suite/base/BaseHandler.t.sol new file mode 100644 index 000000000..c8c5dad60 --- /dev/null +++ b/invariants/protocol-suite/base/BaseHandler.t.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import {CommonHelpers} from '../../shared/utils/CommonHelpers.sol'; +import {HookAggregator} from '../hooks/HookAggregator.t.sol'; + +/// @title BaseHandler +/// @notice Contains common logic for all handlers +/// @dev inherits all suite assertions since per action assertions are implmenteds in the handlers +contract BaseHandler is HookAggregator, CommonHelpers { + /////////////////////////////////////////////////////////////////////////////////////////////// + // MODIFIERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /////////////////////////////////////////////////////////////////////////////////////////////// + // SHARED VARIABLES // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// +} diff --git a/invariants/protocol-suite/base/BaseHooks.t.sol b/invariants/protocol-suite/base/BaseHooks.t.sol new file mode 100644 index 000000000..af6c30598 --- /dev/null +++ b/invariants/protocol-suite/base/BaseHooks.t.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +// Contracts +import {ProtocolAssertions} from './ProtocolAssertions.t.sol'; + +// Test Contracts +import {SpecAggregator} from '../SpecAggregator.t.sol'; + +/// @title BaseHooks +/// @notice Contains common logic for all hooks +/// @dev inherits all suite assertions since per-action assertions are implemented in the handlers +/// @dev inherits SpecAggregator +contract BaseHooks is ProtocolAssertions, SpecAggregator { + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// +} diff --git a/invariants/protocol-suite/base/BaseStorage.t.sol b/invariants/protocol-suite/base/BaseStorage.t.sol new file mode 100644 index 000000000..bec6faa54 --- /dev/null +++ b/invariants/protocol-suite/base/BaseStorage.t.sol @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import {EnumerableSet} from 'src/dependencies/openzeppelin/EnumerableSet.sol'; +import {TestnetERC20} from 'tests/mocks/TestnetERC20.sol'; +import {IHub} from 'src/hub/interfaces/IHub.sol'; +import {ITreasurySpoke} from 'src/spoke/TreasurySpoke.sol'; +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; +import {IAaveOracle} from 'src/spoke/interfaces/IAaveOracle.sol'; +import {IAssetInterestRateStrategy} from 'src/hub/AssetInterestRateStrategy.sol'; +import {IAccessManagerEnumerable} from 'src/access/interfaces/IAccessManagerEnumerable.sol'; +import {IHubConfigurator} from 'src/hub/interfaces/IHubConfigurator.sol'; +import {ISpokeConfigurator} from 'src/spoke/interfaces/ISpokeConfigurator.sol'; + +import {Actor} from '../../shared/utils/Actor.sol'; + +/// @notice BaseStorage contract for all test contracts, works in tandem with BaseTest +abstract contract BaseStorage { + /////////////////////////////////////////////////////////////////////////////////////////////// + // CONSTANTS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + uint256 constant MAX_TOKEN_AMOUNT = 1e29; + + uint256 constant ONE_DAY = 1 days; + uint256 constant ONE_YEAR = 365 days; + uint256 constant ONE_MONTH = ONE_YEAR / 12; + + uint256 internal constant NUMBER_OF_ACTORS = 3; + uint256 internal constant INITIAL_COLL_BALANCE = 1e21; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // ACTORS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice The address of the admin, the tester itself + address internal admin = address(this); + + /// @notice Stores the actor during a handler call + Actor internal actor; + + /// @notice Mapping of fuzzer user addresses to actors + mapping(address user => Actor) internal userToActor; + + /// @notice Array of all actor addresses + EnumerableSet.AddressSet internal actors; + + /// @notice The address that is targeted when executing an action (OPTIONAL) + address internal targetActor; + + /// @notice The signature of the action that is being executed + bytes4 internal currentActionSignature; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // ASSETS STORAGE // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice The USDC token + TestnetERC20 internal usdc; + /// @notice The WETH token + TestnetERC20 internal weth; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // SUITE STORAGE // + /////////////////////////////////////////////////////////////////////////////////////////////// + + // HUB CONTRACTS + IHub internal hub1; + IHub internal hub2; + IAssetInterestRateStrategy internal irStrategy1; + IAssetInterestRateStrategy internal irStrategy2; + IHubConfigurator internal hubConfigurator; + + // SPOKE CONTRACTS + ITreasurySpoke internal treasurySpoke1; + ITreasurySpoke internal treasurySpoke2; + ISpoke internal spoke1; + ISpoke internal spoke2; + ISpokeConfigurator internal spokeConfigurator; + + // ORACLES + IAaveOracle internal oracle1; + IAaveOracle internal oracle2; + + // CONFIGURATION + IAccessManagerEnumerable internal accessManager; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // EXTRA VARIABLES // + /////////////////////////////////////////////////////////////////////////////////////////////// + + // ASSETS + /// @notice Array of base assets for the suite + AssetInfo[] internal baseAssets; + + // HUB + uint256 internal hub1WethAssetId; + uint256 internal hub1UsdcAssetId; + uint256 internal hub2WethAssetId; + uint256 internal hub2UsdcAssetId; + + /// @notice Array of hub addresses for the suite + EnumerableSet.AddressSet internal hubs; + /// @notice Spoke configurations + mapping(address => HubInfo) internal hubInfo; + /// @notice Hub assetIds + mapping(address => uint256[]) internal hubAssetIds; + + // SPOKES + /// @notice Array of spokes addresses for the suite + EnumerableSet.AddressSet internal spokes; + /// @notice Array of treasury spoke addresses + EnumerableSet.AddressSet internal treasurySpokes; + /// @notice spokesAddresses + treasurySpoke address + address[] internal allSpokes; + /// @notice Spoke configurations + mapping(ISpoke => SpokeInfo) internal spokeInfo; + /// @notice Spoke reserveIds to global assetIds + mapping(address => mapping(uint256 => uint256)) internal reserveIdToAssetId; + /// @notice Spoke assetIds to reserveIds info + mapping(address => mapping(uint256 => uint256)) internal assetIdToReserveId; + /// @notice Spoke reserveIds to hub addresses + mapping(address => mapping(uint256 => address)) internal reserveIdToHubAddress; + + // PRICE FEEDS + address[] internal priceFeeds; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // STRUCTS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + struct SpokeInfo { + ReserveInfo weth; + ReserveInfo usdc; + ReserveInfo weth2; + ReserveInfo usdc2; + uint256 MAX_ALLOWED_ASSET_ID; + } + + struct HubInfo { + address treasurySpoke; + address irStrategy; + } + + struct ReserveInfo { + uint256 reserveId; + ISpoke.ReserveConfig reserveConfig; + ISpoke.DynamicReserveConfig dynReserveConfig; + } + + struct AssetInfo { + address underlying; + uint8 decimals; + } +} diff --git a/invariants/protocol-suite/base/BaseTest.t.sol b/invariants/protocol-suite/base/BaseTest.t.sol new file mode 100644 index 000000000..d7b961aff --- /dev/null +++ b/invariants/protocol-suite/base/BaseTest.t.sol @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +// Libraries +import {Vm} from 'forge-std/Base.sol'; +import {StdUtils} from 'forge-std/StdUtils.sol'; +import {EnumerableSet} from 'src/dependencies/openzeppelin/EnumerableSet.sol'; +import {Constants} from 'tests/Constants.sol'; + +// Interfaces +import {IERC20} from 'src/dependencies/openzeppelin/IERC20.sol'; +import {IHub} from 'src/hub/interfaces/IHub.sol'; +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; + +// Utils +import {Actor} from '../../shared/utils/Actor.sol'; +import {PropertiesConstants} from '../../shared/utils/PropertiesConstants.sol'; +import {StdAsserts} from '../../shared/utils/StdAsserts.sol'; + +// Base +import {BaseStorage} from './BaseStorage.t.sol'; + +/// @notice Base contract for all test contracts extends BaseStorage +/// @dev Provides setup modifier and cheat code setup +/// @dev inherits Storage, Testing constants assertions and utils needed for testing +abstract contract BaseTest is BaseStorage, PropertiesConstants, StdAsserts, StdUtils { + using EnumerableSet for EnumerableSet.AddressSet; + + bool public IS_TEST = true; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // ACTOR PROXY MECHANISM // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @dev Actor proxy mechanism + modifier setup() virtual { + actor = userToActor[msg.sender]; + _; + delete actor; + } + + /// @dev Solves medusa backward time warp issue + modifier monotonicTimestamp() virtual { + /// @dev Implement monotonic timestamp if needed + _; + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // CALLBACKS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + receive() external payable {} + + /////////////////////////////////////////////////////////////////////////////////////////////// + // CHEAT CODE SETUP // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @dev Cheat code address, 0x7109709ECfa91a80626fF3989D68f67F5b1DD12D. + address internal constant VM_ADDRESS = address(uint160(uint256(keccak256('hevm cheat code')))); + + /// @dev Virtual machine instance + Vm internal constant vm = Vm(VM_ADDRESS); + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS: RANDOM GETTERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Get a random actor proxy address + function _getRandomActor(uint256 _i) internal view returns (address) { + uint256 _actorIndex = _i % actors.length(); + return actors.at(_actorIndex); + } + + /// @notice Helper function to get a random base asset + function _getRandomBaseAsset(uint256 i) internal view returns (address) { + uint256 _assetIndex = i % baseAssets.length; + return baseAssets[_assetIndex].underlying; + } + + /// @notice Helper function to get random base asset full info + function _getRandomBaseAssetFullInfo(uint256 i) internal view returns (AssetInfo memory) { + uint256 _assetIndex = i % baseAssets.length; + return baseAssets[_assetIndex]; + } + + /// @notice Helper function to get a random hub asset id + function _getRandomHubAssetId(address hub, uint256 i) internal view returns (uint256) { + uint256 _assetIndex = i % hubAssetIds[hub].length; + return hubAssetIds[hub][_assetIndex]; + } + + /// @notice Helper function to get a random spoke address + function _getRandomSpoke(uint256 i) internal view returns (address) { + uint256 _spokeIndex = i % spokes.length(); + return spokes.at(_spokeIndex); + } + + /// @notice Helper function to get a random reserve id from a spoke + function _getRandomReserveId(address spoke, uint256 i) internal view returns (uint256) { + return i % ISpoke(spoke).getReserveCount(); + } + + /// @notice Helper function to get a random price feed address + function _getRandomPriceFeed(uint256 i) internal view returns (address) { + uint256 _priceFeedIndex = i % priceFeeds.length; + return priceFeeds[_priceFeedIndex]; + } + + /// @notice Helper function to get a random hub address + function _getRandomHub(uint256 i) internal view returns (address) { + uint256 _hubIndex = i % hubs.length(); + return hubs.at(_hubIndex); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS: GETTERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function _getAssetId(address spoke, uint256 reserveId) internal view returns (uint256) { + return reserveIdToAssetId[spoke][reserveId]; + } + + function _getReserveId(address spoke, uint256 assetId) internal view returns (uint256) { + return assetIdToReserveId[spoke][assetId]; + } + + function _getHubAddress(address spoke, uint256 reserveId) internal view returns (address) { + return reserveIdToHubAddress[spoke][reserveId]; + } + + function _isHealthy(address spoke, address user) internal view returns (bool) { + return + ISpoke(spoke).getUserAccountData(user).healthFactor >= + Constants.HEALTH_FACTOR_LIQUIDATION_THRESHOLD; + } + + /// @notice Returns true if the reserve/spoke is admin-blocked for the given action + function _isReserveActionBlocked( + address spoke, + uint256 reserveId, + bool checkFrozen, + bool checkBorrowable + ) internal view returns (bool) { + ISpoke.ReserveConfig memory config = ISpoke(spoke).getReserveConfig(reserveId); + if (config.paused) return true; + if (checkFrozen && config.frozen) return true; + if (checkBorrowable && !config.borrowable) return true; + address hubAddress = _getHubAddress(spoke, reserveId); + uint256 assetId = _getAssetId(spoke, reserveId); + IHub.SpokeData memory spokeData = IHub(hubAddress).getSpoke(assetId, spoke); + if (!spokeData.active || spokeData.halted) return true; + return false; + } + + /// @notice Returns true if the current actor can act on behalf of `onBehalfOf` on the spoke + function _isAuthorized(address spoke, address onBehalfOf) internal view returns (bool) { + return ISpoke(spoke).isPositionManager(onBehalfOf, address(actor)); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Get a random address + function _makeAddr(string memory name) internal pure returns (address addr) { + uint256 privateKey = uint256(keccak256(abi.encodePacked(name))); + addr = vm.addr(privateKey); + } + + /// @notice Helper function to transfer tokens by actor + function _transferByActor(address token, address to, uint256 amount) internal { + (bool ok, bytes memory ret) = actor.proxy(token, abi.encodeCall(IERC20.transfer, (to, amount))); + require(ok, string(ret)); + } + + /// @notice Helper function to approve tokens by actor + function _approveByActor(address token, address spender, uint256 amount) internal { + (bool ok, bytes memory ret) = actor.proxy( + token, + abi.encodeCall(IERC20.approve, (spender, amount)) + ); + require(ok, string(ret)); + } + + /// @notice Helper function to calculate burnt interest in assets terms (originating from virtual shares) + function _calculateBurntInterest(IHub hub_, uint256 assetId_) internal view returns (uint256) { + uint256 totalAssets = hub_.getAddedAssets(assetId_); + uint256 totalShares = hub_.getAddedShares(assetId_); + return totalAssets - hub_.previewRemoveByShares(assetId_, totalShares); + } + + function _underlying(ISpoke spoke, uint256 reserveId) internal view returns (address) { + return spoke.getReserve(reserveId).underlying; + } + + function _underlying(address spoke, uint256 reserveId) internal view returns (address) { + return _underlying(ISpoke(spoke), reserveId); + } +} diff --git a/invariants/protocol-suite/base/ProtocolAssertions.t.sol b/invariants/protocol-suite/base/ProtocolAssertions.t.sol new file mode 100644 index 000000000..0b6b9bd1a --- /dev/null +++ b/invariants/protocol-suite/base/ProtocolAssertions.t.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +// Base +import {BaseTest} from './BaseTest.t.sol'; +import {StdAsserts} from '../../shared/utils/StdAsserts.sol'; + +/// @title ProtocolAssertions +/// @notice Helper contract for protocol specific assertions +contract ProtocolAssertions is StdAsserts, BaseTest {} diff --git a/invariants/protocol-suite/handlers/hub/HubAdminHandler.t.sol b/invariants/protocol-suite/handlers/hub/HubAdminHandler.t.sol new file mode 100644 index 000000000..5f9360425 --- /dev/null +++ b/invariants/protocol-suite/handlers/hub/HubAdminHandler.t.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +// Interfaces +import {IHub} from 'src/hub/interfaces/IHub.sol'; + +// Test Contracts +import {HubAdminHandlerBase} from '../../../hub-suite/handlers/HubAdminHandler.t.sol'; +import {BaseHandler} from '../../base/BaseHandler.t.sol'; + +/// @title HubAdminHandler +/// @notice Protocol-suite concrete handler — multi-hub variant that selects hubs indirectly +/// through spoke → reserveId → hub mappings, covering all hub/asset combinations +/// reachable from the deployed spokes. +contract HubAdminHandler is BaseHandler, HubAdminHandlerBase { + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function _beforeHook() internal override { + _before(); + } + + function _afterHook() internal override { + _after(); + } + + /// @dev Picks a random spoke, derives a random reserveId, then resolves the backing hub. + function _getRandomHub(uint8 i) internal view override returns (IHub) { + address spoke = _getRandomSpoke(i); + uint256 reserveId = _getRandomReserveId( + spoke, + _bound(_randomize(i, vm.toString(spoke)), 0, type(uint8).max) + ); + return IHub(_getHubAddress(spoke, reserveId)); + } + + /// @dev Picks a random spoke, derives a random reserveId, then resolves the hub-level assetId. + function _getRandomAssetId(uint8 i) internal view override returns (uint256) { + address spoke = _getRandomSpoke(i); + uint256 reserveId = _getRandomReserveId( + spoke, + _bound(_randomize(i, vm.toString(spoke)), 0, type(uint8).max) + ); + return _getAssetId(spoke, reserveId); + } +} diff --git a/invariants/protocol-suite/handlers/hub/HubConfiguratorHandler.t.sol b/invariants/protocol-suite/handlers/hub/HubConfiguratorHandler.t.sol new file mode 100644 index 000000000..32e8da423 --- /dev/null +++ b/invariants/protocol-suite/handlers/hub/HubConfiguratorHandler.t.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +// Interfaces +import {IERC20} from 'src/dependencies/openzeppelin/IERC20.sol'; +import {IHubConfiguratorHandler} from '../interfaces/IHubConfiguratorHandler.sol'; + +// Test Contracts +import {Actor} from '../../../shared/utils/Actor.sol'; +import {BaseHandler} from '../../base/BaseHandler.t.sol'; + +/// @title HubConfiguratorHandler +/// @notice Handler test contract for a set of actions +/// @dev Inputs are bounded to Hub validation constraints so admin actions don't unnecessarily +/// discard fuzzer runs. +contract HubConfiguratorHandler is BaseHandler, IHubConfiguratorHandler { + /////////////////////////////////////////////////////////////////////////////////////////////// + // STATE VARIABLES // + /////////////////////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////////////////////// + // ACTIONS // + /////////////////////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////////////////////// + // SPOKE CONFIG // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function updateSpokeAddCap(uint256 addCap, uint8 i, uint8 j, uint8 k) external setup { + address hub = _getRandomHub(i); + uint256 assetId = _getRandomHubAssetId(hub, j); + address spoke = _getRandomSpoke(k); + addCap = _bound(addCap, 0, MAX_ALLOWED_SPOKE_CAP); + hubConfigurator.updateSpokeAddCap(hub, assetId, spoke, addCap); + } + + function updateSpokeDrawCap(uint256 drawCap, uint8 i, uint8 j, uint8 k) external setup { + address hub = _getRandomHub(i); + uint256 assetId = _getRandomHubAssetId(hub, j); + address spoke = _getRandomSpoke(k); + drawCap = _bound(drawCap, 0, MAX_ALLOWED_SPOKE_CAP); + hubConfigurator.updateSpokeDrawCap(hub, assetId, spoke, drawCap); + } + + function updateSpokeRiskPremiumThreshold( + uint256 riskPremiumThreshold, + uint8 i, + uint8 j, + uint8 k + ) external setup { + address hub = _getRandomHub(i); + uint256 assetId = _getRandomHubAssetId(hub, j); + address spoke = _getRandomSpoke(k); + riskPremiumThreshold = _bound(riskPremiumThreshold, 0, MAX_RISK_PREMIUM_THRESHOLD); + hubConfigurator.updateSpokeRiskPremiumThreshold(hub, assetId, spoke, riskPremiumThreshold); + } + + function updateSpokeHalted(bool halted, uint8 i, uint8 j, uint8 k) external setup { + address hub = _getRandomHub(i); + uint256 assetId = _getRandomHubAssetId(hub, j); + address spoke = _getRandomSpoke(k); + hubConfigurator.updateSpokeHalted(hub, assetId, spoke, halted); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// +} diff --git a/invariants/protocol-suite/handlers/interfaces/IHubConfiguratorHandler.sol b/invariants/protocol-suite/handlers/interfaces/IHubConfiguratorHandler.sol new file mode 100644 index 000000000..adbdbbeaf --- /dev/null +++ b/invariants/protocol-suite/handlers/interfaces/IHubConfiguratorHandler.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +/// @title IHubConfiguratorHandler +/// @notice Interface for the HubConfiguratorHandler +interface IHubConfiguratorHandler { + function updateSpokeAddCap(uint256 addCap, uint8 i, uint8 j, uint8 k) external; + + function updateSpokeDrawCap(uint256 drawCap, uint8 i, uint8 j, uint8 k) external; + + function updateSpokeRiskPremiumThreshold( + uint256 riskPremiumThreshold, + uint8 i, + uint8 j, + uint8 k + ) external; + + function updateSpokeHalted(bool halted, uint8 i, uint8 j, uint8 k) external; +} diff --git a/invariants/protocol-suite/handlers/interfaces/IHubHandler.sol b/invariants/protocol-suite/handlers/interfaces/IHubHandler.sol new file mode 100644 index 000000000..007eb885d --- /dev/null +++ b/invariants/protocol-suite/handlers/interfaces/IHubHandler.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +/// @title IHubHandler +/// @notice Interface for the HubHandler +interface IHubHandler {} diff --git a/invariants/protocol-suite/handlers/interfaces/ISpokeConfiguratorHandler.sol b/invariants/protocol-suite/handlers/interfaces/ISpokeConfiguratorHandler.sol new file mode 100644 index 000000000..3b2015a75 --- /dev/null +++ b/invariants/protocol-suite/handlers/interfaces/ISpokeConfiguratorHandler.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +/// @title ISpokeConfiguratorHandler +/// @notice Interface for the SpokeConfiguratorHandler +interface ISpokeConfiguratorHandler { + // Reserve config + function updateCollateralRisk(uint256 collateralRisk, uint8 i, uint8 j) external; + + function updatePaused(bool halted, uint8 i, uint8 j) external; + + function updateFrozen(bool frozen, uint8 i, uint8 j) external; + + function updateBorrowable(bool borrowable, uint8 i, uint8 j) external; + + function updateReceiveSharesEnabled(bool receiveSharesEnabled, uint8 i, uint8 j) external; + + function pauseAllReserves(uint8 i) external; + + function freezeAllReserves(uint8 i) external; + + // Liquidation config + function updateLiquidationTargetHealthFactor(uint256 targetHealthFactor, uint8 i) external; + + function updateHealthFactorForMaxBonus(uint256 healthFactorForMaxBonus, uint8 i) external; + + function updateLiquidationBonusFactor(uint256 liquidationBonusFactor, uint8 i) external; + + // Dynamic reserve config + function addCollateralFactor(uint256 collateralFactor, uint8 i, uint8 j) external; + + function updateCollateralFactor(uint256 collateralFactor, uint8 i, uint8 j, uint8 k) external; + + function addMaxLiquidationBonus(uint256 maxLiquidationBonus, uint8 i, uint8 j) external; + + function updateMaxLiquidationBonus( + uint256 maxLiquidationBonus, + uint8 i, + uint8 j, + uint8 k + ) external; + + function addLiquidationFee(uint256 liquidationFee, uint8 i, uint8 j) external; + + function updateLiquidationFee(uint256 liquidationFee, uint8 i, uint8 j, uint8 k) external; +} diff --git a/invariants/protocol-suite/handlers/interfaces/ISpokeHandler.sol b/invariants/protocol-suite/handlers/interfaces/ISpokeHandler.sol new file mode 100644 index 000000000..2b16c0767 --- /dev/null +++ b/invariants/protocol-suite/handlers/interfaces/ISpokeHandler.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +/// @title ISpokeHandler +/// @notice Interface for the SpokeHandler +interface ISpokeHandler { + function supply(uint256 amount, uint8 i, uint8 j, uint8 k) external; + function withdraw(uint256 amount, uint8 i, uint8 j, uint8 k) external; + function borrow(uint256 amount, uint8 i, uint8 j, uint8 k) external; + function repay(uint256 amount, uint8 i, uint8 j, uint8 k) external; + function liquidationCall( + uint256 debtToCover, + bool receiveShares, + uint8 i, + uint8 j, + uint8 k, + uint8 l + ) external; + function setUsingAsCollateral(bool usingAsCollateral, uint8 i, uint8 j) external; + function updateUserRiskPremium(uint8 i) external; + function updateUserDynamicConfig(uint8 i) external; + function setUserPositionManager(bool approve, uint8 i, uint8 j) external; +} diff --git a/invariants/protocol-suite/handlers/interfaces/ITreasurySpokeHandler.sol b/invariants/protocol-suite/handlers/interfaces/ITreasurySpokeHandler.sol new file mode 100644 index 000000000..64f6e9454 --- /dev/null +++ b/invariants/protocol-suite/handlers/interfaces/ITreasurySpokeHandler.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +/// @title ITreasurySpokeHandler +/// @notice Interface for the TreasurySpokeHandler +interface ITreasurySpokeHandler { + function supply(uint256 amount, uint8 i, uint8 j) external; + + function withdraw(uint256 amount, uint8 i, uint8 j) external; + + function transfer(uint256 amount, uint8 i, uint8 j, uint8 k) external; +} diff --git a/invariants/protocol-suite/handlers/simulators/DonationAttackHandler.t.sol b/invariants/protocol-suite/handlers/simulators/DonationAttackHandler.t.sol new file mode 100644 index 000000000..e3d98a5b1 --- /dev/null +++ b/invariants/protocol-suite/handlers/simulators/DonationAttackHandler.t.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; +import {EnumerableSet} from 'src/dependencies/openzeppelin/EnumerableSet.sol'; +import {BaseHandler} from '../../base/BaseHandler.t.sol'; +import {TestnetERC20} from 'tests/mocks/TestnetERC20.sol'; + +/// @title DonationAttackHandler +/// @notice Handler test contract for a set of actions +contract DonationAttackHandler is BaseHandler { + using EnumerableSet for EnumerableSet.AddressSet; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // STATE VARIABLES // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /////////////////////////////////////////////////////////////////////////////////////////////// + // ACTIONS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /////////////////////////////////////////////////////////////////////////////////////////////// + // OWNER ACTIONS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function donateUnderlyingToHub(uint256 amount, uint8 i, uint8 j) external { + address hub = _getRandomHub(j); + address underlying = _getRandomBaseAsset(i); + + // Register all spoke/reserve pairs that map to this hub so hub postconditions fire + _registerAllReservesForHub(hub); + + _before(); + TestnetERC20(underlying).mint(hub, amount); + _after(); + } + + function donateUnderlyingToSpoke(uint256 amount, uint8 i, uint8 j) external { + address spoke = _getRandomSpoke(j); + address underlying = _getRandomBaseAsset(i); + + // Register all reserves for this spoke so hub postconditions fire + _registerAllReservesForSpoke(spoke); + + _before(); + TestnetERC20(underlying).mint(spoke, amount); + _after(); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @dev Registers a user-to-check entry for every reserve of every spoke that is connected + /// to the given hub, so that hub-level postconditions (GPOST_HUB_A..G) are evaluated. + /// Uses address(this) as the user since donations don't act on a specific user. + function _registerAllReservesForHub(address hubAddress) internal { + for (uint256 s; s < spokes.length(); s++) { + address spoke = spokes.at(s); + uint256 reserveCount = ISpoke(spoke).getReserveCount(); + for (uint256 r; r < reserveCount; r++) { + if (reserveIdToHubAddress[spoke][r] == hubAddress) { + _registerUserToCheck(spoke, r, address(this)); + } + } + } + } + + /// @dev Registers a user-to-check entry for every reserve of the given spoke, + /// so that hub-level postconditions are evaluated for all assets of the spoke. + /// Uses address(this) as the user since donations don't act on a specific user. + function _registerAllReservesForSpoke(address spoke) internal { + uint256 reserveCount = ISpoke(spoke).getReserveCount(); + for (uint256 r; r < reserveCount; ++r) { + _registerUserToCheck(spoke, r, address(this)); + } + } +} diff --git a/invariants/protocol-suite/handlers/simulators/PriceFeedSimulatorHandler.t.sol b/invariants/protocol-suite/handlers/simulators/PriceFeedSimulatorHandler.t.sol new file mode 100644 index 000000000..892feed73 --- /dev/null +++ b/invariants/protocol-suite/handlers/simulators/PriceFeedSimulatorHandler.t.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +// Test Contracts +import {BaseHandler} from '../../base/BaseHandler.t.sol'; +import {MockPriceFeedSimulator} from '../../../shared/mocks/MockPriceFeedSimulator.sol'; + +import 'forge-std/console.sol'; + +/// @title PriceFeedSimulatorHandler +/// @notice Handler test contract for a set of actions +contract PriceFeedSimulatorHandler is BaseHandler { + /////////////////////////////////////////////////////////////////////////////////////////////// + // STATE VARIABLES // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /////////////////////////////////////////////////////////////////////////////////////////////// + // ACTIONS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /////////////////////////////////////////////////////////////////////////////////////////////// + // OWNER ACTIONS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function setPrice(int256 price, uint8 i) public { + address priceFeed = _getRandomPriceFeed(i); + + price = clampBetween(price, PRICE_MIN, PRICE_MAX); + + MockPriceFeedSimulator(priceFeed).setPrice(price); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// +} diff --git a/invariants/protocol-suite/handlers/spoke/SpokeConfiguratorHandler.t.sol b/invariants/protocol-suite/handlers/spoke/SpokeConfiguratorHandler.t.sol new file mode 100644 index 000000000..a25246ce5 --- /dev/null +++ b/invariants/protocol-suite/handlers/spoke/SpokeConfiguratorHandler.t.sol @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +// Interfaces +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; +import {IERC20} from 'src/dependencies/openzeppelin/IERC20.sol'; +import {ISpokeConfiguratorHandler} from '../interfaces/ISpokeConfiguratorHandler.sol'; + +// Libraries +import {PercentageMath} from 'src/libraries/math/PercentageMath.sol'; + +// Test Contracts +import {Actor} from '../../../shared/utils/Actor.sol'; +import {BaseHandler} from '../../base/BaseHandler.t.sol'; + +/// @title SpokeConfiguratorHandler +/// @notice Handler test contract for a set of actions +/// @dev Inputs are bounded to Spoke._validate* constraints on *admin* actions don't unnecessarily +/// discard fuzz inputs. +contract SpokeConfiguratorHandler is BaseHandler, ISpokeConfiguratorHandler { + using PercentageMath for uint256; + /////////////////////////////////////////////////////////////////////////////////////////////// + // STATE VARIABLES // + /////////////////////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////////////////////// + // ACTIONS // + /////////////////////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////////////////////// + // OWNER ACTIONS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /////////////////////////////////////////////////////////////////////////////////////////////// + // RESERVE CONFIG // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function updateCollateralRisk(uint256 collateralRisk, uint8 i, uint8 j) external setup { + address spoke = _getRandomSpoke(i); + uint256 reserveId = _getRandomReserveId(spoke, j); + collateralRisk = _bound(collateralRisk, 0, MAX_ALLOWED_COLLATERAL_RISK); + spokeConfigurator.updateCollateralRisk(spoke, reserveId, collateralRisk); + } + + function updatePaused(bool halted, uint8 i, uint8 j) external setup { + address spoke = _getRandomSpoke(i); + uint256 reserveId = _getRandomReserveId(spoke, j); + spokeConfigurator.updatePaused(spoke, reserveId, halted); + } + + function updateFrozen(bool frozen, uint8 i, uint8 j) external setup { + address spoke = _getRandomSpoke(i); + uint256 reserveId = _getRandomReserveId(spoke, j); + spokeConfigurator.updateFrozen(spoke, reserveId, frozen); + } + + function updateBorrowable(bool borrowable, uint8 i, uint8 j) external setup { + address spoke = _getRandomSpoke(i); + uint256 reserveId = _getRandomReserveId(spoke, j); + spokeConfigurator.updateBorrowable(spoke, reserveId, borrowable); + } + + function updateReceiveSharesEnabled(bool receiveSharesEnabled, uint8 i, uint8 j) external setup { + address spoke = _getRandomSpoke(i); + uint256 reserveId = _getRandomReserveId(spoke, j); + spokeConfigurator.updateReceiveSharesEnabled(spoke, reserveId, receiveSharesEnabled); + } + + function pauseAllReserves(uint8 i) external setup { + address spoke = _getRandomSpoke(i); + spokeConfigurator.pauseAllReserves(spoke); + } + + function freezeAllReserves(uint8 i) external setup { + address spoke = _getRandomSpoke(i); + spokeConfigurator.freezeAllReserves(spoke); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // LIQUIDATION CONFIG // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function updateLiquidationTargetHealthFactor(uint256 targetHealthFactor, uint8 i) external setup { + address spoke = _getRandomSpoke(i); + targetHealthFactor = _bound( + targetHealthFactor, + HEALTH_FACTOR_LIQUIDATION_THRESHOLD, + MAX_TARGET_HEALTH_FACTOR + ); + spokeConfigurator.updateLiquidationTargetHealthFactor(spoke, targetHealthFactor); + } + + function updateHealthFactorForMaxBonus(uint256 healthFactorForMaxBonus, uint8 i) external setup { + address spoke = _getRandomSpoke(i); + healthFactorForMaxBonus = _bound( + healthFactorForMaxBonus, + 0, + HEALTH_FACTOR_LIQUIDATION_THRESHOLD - 1 + ); + spokeConfigurator.updateHealthFactorForMaxBonus(spoke, healthFactorForMaxBonus); + } + + function updateLiquidationBonusFactor(uint256 liquidationBonusFactor, uint8 i) external setup { + address spoke = _getRandomSpoke(i); + liquidationBonusFactor = _bound(liquidationBonusFactor, 0, PERCENTAGE_FACTOR); + spokeConfigurator.updateLiquidationBonusFactor(spoke, liquidationBonusFactor); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // DYNAMIC CONFIG // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function addCollateralFactor(uint256 collateralFactor, uint8 i, uint8 j) external setup { + address spoke = _getRandomSpoke(i); + uint256 reserveId = _getRandomReserveId(spoke, j); + + uint256 maxCf = _collateralFactorUpperBound(spoke, reserveId); + collateralFactor = _bound(collateralFactor, 1, maxCf); + spokeConfigurator.addCollateralFactor(spoke, reserveId, uint16(collateralFactor)); + } + + function updateCollateralFactor( + uint256 collateralFactor, + uint8 i, + uint8 j, + uint8 k + ) external setup { + address spoke = _getRandomSpoke(i); + uint256 reserveId = _getRandomReserveId(spoke, j); + uint32 dynamicConfigKey = _getRandomDynamicConfigKey(spoke, reserveId, k); + + uint256 maxCf = _collateralFactorUpperBound(spoke, reserveId, dynamicConfigKey); + collateralFactor = _bound(collateralFactor, 1, maxCf); + spokeConfigurator.updateCollateralFactor( + spoke, + reserveId, + dynamicConfigKey, + uint16(collateralFactor) + ); + } + + function addMaxLiquidationBonus(uint256 maxLiquidationBonus, uint8 i, uint8 j) external setup { + address spoke = _getRandomSpoke(i); + uint256 reserveId = _getRandomReserveId(spoke, j); + + uint256 maxMlb = _maxLiquidationBonusUpperBound(spoke, reserveId); + maxLiquidationBonus = _bound(maxLiquidationBonus, PERCENTAGE_FACTOR, maxMlb); + spokeConfigurator.addMaxLiquidationBonus(spoke, reserveId, maxLiquidationBonus); + } + + function updateMaxLiquidationBonus( + uint256 maxLiquidationBonus, + uint8 i, + uint8 j, + uint8 k + ) external setup { + address spoke = _getRandomSpoke(i); + uint256 reserveId = _getRandomReserveId(spoke, j); + uint32 dynamicConfigKey = _getRandomDynamicConfigKey(spoke, reserveId, k); + + uint256 maxMlb = _maxLiquidationBonusUpperBound(spoke, reserveId, dynamicConfigKey); + maxLiquidationBonus = _bound(maxLiquidationBonus, PERCENTAGE_FACTOR, maxMlb); + spokeConfigurator.updateMaxLiquidationBonus( + spoke, + reserveId, + dynamicConfigKey, + maxLiquidationBonus + ); + } + + function addLiquidationFee(uint256 liquidationFee, uint8 i, uint8 j) external setup { + address spoke = _getRandomSpoke(i); + uint256 reserveId = _getRandomReserveId(spoke, j); + liquidationFee = _bound(liquidationFee, 0, PERCENTAGE_FACTOR); + spokeConfigurator.addLiquidationFee(spoke, reserveId, liquidationFee); + } + + function updateLiquidationFee(uint256 liquidationFee, uint8 i, uint8 j, uint8 k) external setup { + address spoke = _getRandomSpoke(i); + uint256 reserveId = _getRandomReserveId(spoke, j); + uint32 dynamicConfigKey = _getRandomDynamicConfigKey(spoke, reserveId, k); + liquidationFee = _bound(liquidationFee, 0, PERCENTAGE_FACTOR); + spokeConfigurator.updateLiquidationFee(spoke, reserveId, dynamicConfigKey, liquidationFee); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @dev Returns the latest dynamic config for a reserve. + function _getLatestDynamicConfig( + address spoke, + uint256 reserveId + ) internal view returns (ISpoke.DynamicReserveConfig memory) { + uint32 latestKey = ISpoke(spoke).getReserve(reserveId).dynamicConfigKey; + return ISpoke(spoke).getDynamicReserveConfig(reserveId, latestKey); + } + + /// @dev Returns a random dynamic config key in [0, reserve.dynamicConfigKey]. + function _getRandomDynamicConfigKey( + address spoke, + uint256 reserveId, + uint8 k + ) internal view returns (uint32) { + uint32 latestKey = ISpoke(spoke).getReserve(reserveId).dynamicConfigKey; + return uint32(_bound(k, 0, latestKey)); + } + + /// @dev Upper bound for collateralFactor derived from the reserve's latest maxLiquidationBonus. + function _collateralFactorUpperBound( + address spoke, + uint256 reserveId + ) internal view returns (uint256) { + uint256 maxLiquidationBonus = _getLatestDynamicConfig(spoke, reserveId).maxLiquidationBonus; + return _collateralFactorUpperBound(maxLiquidationBonus); + } + + /// @dev Upper bound for collateralFactor at a specific dynamic config key. + function _collateralFactorUpperBound( + address spoke, + uint256 reserveId, + uint32 dynamicConfigKey + ) internal view returns (uint256) { + uint256 maxLiquidationBonus = ISpoke(spoke) + .getDynamicReserveConfig(reserveId, dynamicConfigKey) + .maxLiquidationBonus; + return _collateralFactorUpperBound(maxLiquidationBonus); + } + + /// @dev Upper bound for maxLiquidationBonus derived from the reserve's latest collateralFactor. + function _maxLiquidationBonusUpperBound( + address spoke, + uint256 reserveId + ) internal view returns (uint256) { + uint256 collateralFactor = _getLatestDynamicConfig(spoke, reserveId).collateralFactor; + return _maxLiquidationBonusUpperBound(collateralFactor); + } + + /// @dev Upper bound for maxLiquidationBonus at a specific dynamic config key. + function _maxLiquidationBonusUpperBound( + address spoke, + uint256 reserveId, + uint32 dynamicConfigKey + ) internal view returns (uint256) { + uint256 collateralFactor = ISpoke(spoke) + .getDynamicReserveConfig(reserveId, dynamicConfigKey) + .collateralFactor; + return _maxLiquidationBonusUpperBound(collateralFactor); + } + + /// @dev Upper bound for maxLiquidationBonus for a given collateralFactor. + function _maxLiquidationBonusUpperBound( + uint256 collateralFactor + ) internal pure returns (uint256) { + if (collateralFactor == 0) return PERCENTAGE_FACTOR; + return (PercentageMath.PERCENTAGE_FACTOR - 1).percentDivDown(collateralFactor); + } + + /// @dev Upper bound for collateralFactor for a given maxLiquidationBonus. + function _collateralFactorUpperBound( + uint256 maxLiquidationBonus + ) internal pure returns (uint256) { + return (PercentageMath.PERCENTAGE_FACTOR - 1).percentDivDown(maxLiquidationBonus); + } +} diff --git a/invariants/protocol-suite/handlers/spoke/SpokeHandler.t.sol b/invariants/protocol-suite/handlers/spoke/SpokeHandler.t.sol new file mode 100644 index 000000000..3b58b52b7 --- /dev/null +++ b/invariants/protocol-suite/handlers/spoke/SpokeHandler.t.sol @@ -0,0 +1,394 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +// Interfaces +import {ISpoke, ISpokeBase} from 'src/spoke/interfaces/ISpoke.sol'; +import {ISpokeHandler} from '../interfaces/ISpokeHandler.sol'; +import {IERC20} from 'src/dependencies/openzeppelin/IERC20.sol'; + +// Libraries +import {WadRayMath} from 'src/libraries/math/WadRayMath.sol'; +import {MathUtils} from 'src/libraries/math/MathUtils.sol'; +import {Constants} from 'tests/Constants.sol'; + +// Test Contracts +import {Actor} from '../../../shared/utils/Actor.sol'; +import {BaseHandler} from '../../base/BaseHandler.t.sol'; + +/// @title SpokeHandler +/// @notice Handler test contract for a set of actions +contract SpokeHandler is BaseHandler, ISpokeHandler { + using WadRayMath for uint256; + using MathUtils for uint256; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // STATE VARIABLES // + /////////////////////////////////////////////////////////////////////////////////////////////// + + struct LiquidationVars { + // Spoke + address violator; + address liquidator; + address spoke; + address underlying; + // Debt reserve + uint256 debtReserveId; + uint256 collateralReserveId; + // Liquidation + uint256 debtToCover; + uint256 debtLiquidated; + uint256 totalDebtValueBefore; + // Liquidator + uint256 liquidatorCollateralBalanceBefore; + uint256 liquidatorCollateralBalanceAfter; + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // ACTIONS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function supply(uint256 amount, uint8 i, uint8 j, uint8 k) external setup { + address onBehalfOf = _getRandomActor(i); + address spoke = _getRandomSpoke(j); + uint256 reserveId = _getRandomReserveId(spoke, k); + _registerUserToCheck(spoke, reserveId, onBehalfOf); // register user to check post conditions + + _tryMintAndApprove(_underlying(spoke, reserveId), address(actor), spoke, amount); + + _before(); + (bool ok, ) = actor.proxy( + spoke, + abi.encodeCall(ISpokeBase.supply, (reserveId, amount, onBehalfOf)) + ); + + if (ok) { + _after(); + } else { + vm.assume(false); + } + } + + function withdraw(uint256 amount, uint8 i, uint8 j, uint8 k) external setup { + address onBehalfOf = _getRandomActor(i); + address spoke = _getRandomSpoke(j); + uint256 reserveId = _getRandomReserveId(spoke, k); + _registerUserToCheck(spoke, reserveId, onBehalfOf); // register user to check post conditions + bool healthyBefore = _isHealthy(spoke, onBehalfOf); + + _before(); + (bool ok, ) = actor.proxy( + spoke, + abi.encodeCall(ISpokeBase.withdraw, (reserveId, amount, onBehalfOf)) + ); + + if (ok) { + _after(); + + assertTrue(healthyBefore, GPOST_SP_H); + assertTrue(_isHealthy(spoke, onBehalfOf), HSPOST_SP_I); + } else { + vm.assume(false); + } + } + + function borrow(uint256 amount, uint8 i, uint8 j, uint8 k) external setup { + address onBehalfOf = _getRandomActor(i); + address spoke = _getRandomSpoke(j); + uint256 reserveId = _getRandomReserveId(spoke, k); + _registerUserToCheck(spoke, reserveId, onBehalfOf); // register user to check post conditions + bool healthyBefore = _isHealthy(spoke, onBehalfOf); + + _before(); + (bool ok, ) = actor.proxy( + spoke, + abi.encodeCall(ISpokeBase.borrow, (reserveId, amount, onBehalfOf)) + ); + + if (ok) { + _after(); + + assertTrue(healthyBefore, HSPOST_SP_D); + assertTrue(_isHealthy(spoke, onBehalfOf), HSPOST_SP_I); + } else { + vm.assume(false); + } + } + + function repay(uint256 amount, uint8 i, uint8 j, uint8 k) external setup { + address onBehalfOf = _getRandomActor(i); + address spoke = _getRandomSpoke(j); + uint256 reserveId = _getRandomReserveId(spoke, k); + _registerUserToCheck(spoke, reserveId, onBehalfOf); // register user to check post conditions + + _tryMintAndApprove(_underlying(spoke, reserveId), address(actor), spoke, amount); + + _before(); + (bool ok, ) = actor.proxy( + spoke, + abi.encodeCall(ISpokeBase.repay, (reserveId, amount, onBehalfOf)) + ); + + if (ok) { + _after(); + + assertLe( + _userVarsAfter(spoke, reserveId, onBehalfOf).debt.owed, + _userVarsBefore(spoke, reserveId, onBehalfOf).debt.owed, + HSPOST_SP_C + ); + } else { + vm.assume(false); + } + } + + function liquidationCall( + uint256 debtToCover, + bool receiveShares, + uint8 i, + uint8 j, + uint8 k, + uint8 l + ) external setup { + LiquidationVars memory liquidationVars; + liquidationVars.spoke = _getRandomSpoke(j); + liquidationVars.debtToCover = debtToCover; + + liquidationVars.violator = _getRandomActor(i); + liquidationVars.liquidator = address(actor); + + liquidationVars.collateralReserveId = _getRandomReserveId(liquidationVars.spoke, k); + liquidationVars.debtReserveId = _getRandomReserveId(liquidationVars.spoke, l); + liquidationVars.underlying = ISpoke(liquidationVars.spoke) + .getReserve(liquidationVars.collateralReserveId) + .underlying; + + uint256 violatorCollateralBalanceBefore = ISpoke(liquidationVars.spoke).getUserSuppliedAssets( + liquidationVars.collateralReserveId, + liquidationVars.violator + ); + + liquidationVars.totalDebtValueBefore = ISpoke(liquidationVars.spoke) + .getUserAccountData(liquidationVars.violator) + .totalDebtValueRay + .fromRayUp(); + + if (receiveShares) { + liquidationVars.liquidatorCollateralBalanceBefore = ISpoke(liquidationVars.spoke) + .getUserSuppliedAssets(liquidationVars.collateralReserveId, address(actor)); + } else { + liquidationVars.liquidatorCollateralBalanceBefore = IERC20(liquidationVars.underlying) + .balanceOf(address(actor)); + } + + // register users to check post conditions: liquidated user and liquidator for both reserves + _registerUserToCheck( + liquidationVars.spoke, + liquidationVars.debtReserveId, + liquidationVars.violator + ); + _registerUserToCheck( + liquidationVars.spoke, + liquidationVars.collateralReserveId, + liquidationVars.violator + ); + _registerUserToCheck( + liquidationVars.spoke, + liquidationVars.debtReserveId, + liquidationVars.liquidator + ); + _registerUserToCheck( + liquidationVars.spoke, + liquidationVars.collateralReserveId, + liquidationVars.liquidator + ); + + _tryMintAndApprove( + _underlying(liquidationVars.spoke, liquidationVars.debtReserveId), + address(actor), + liquidationVars.spoke, + debtToCover + ); + + _before(); + (bool ok, ) = actor.proxy( + liquidationVars.spoke, + abi.encodeCall( + ISpokeBase.liquidationCall, + ( + liquidationVars.collateralReserveId, + liquidationVars.debtReserveId, + liquidationVars.violator, + liquidationVars.debtToCover, + receiveShares + ) + ) + ); + + if (ok) { + _after(); + + // Calculate the debt liquidated from user-level snapshots (not reserve-level, which + // includes interest accrual on other users' debt and would be inaccurate) + UserVars memory violatorDebtVarsBefore = _userVarsBefore( + liquidationVars.spoke, + liquidationVars.debtReserveId, + liquidationVars.violator + ); + UserVars memory violatorDebtVarsAfter = _userVarsAfter( + liquidationVars.spoke, + liquidationVars.debtReserveId, + liquidationVars.violator + ); + liquidationVars.debtLiquidated = violatorDebtVarsBefore.debt.owed.zeroFloorSub( + violatorDebtVarsAfter.debt.owed + ); + + if (receiveShares) { + liquidationVars.liquidatorCollateralBalanceAfter = ISpoke(liquidationVars.spoke) + .getUserSuppliedAssets(liquidationVars.collateralReserveId, address(actor)); + } else { + liquidationVars.liquidatorCollateralBalanceAfter = IERC20(liquidationVars.underlying) + .balanceOf(address(actor)); + } + + /// HSPOST /// + assertLe(liquidationVars.debtLiquidated, violatorDebtVarsBefore.debt.owed, HSPOST_SP_LIQ_A); + + if ( + liquidationVars.liquidatorCollateralBalanceAfter > + liquidationVars.liquidatorCollateralBalanceBefore + ) { + assertLe( + liquidationVars.liquidatorCollateralBalanceAfter - + liquidationVars.liquidatorCollateralBalanceBefore, + violatorCollateralBalanceBefore, + HSPOST_SP_LIQ_B + ); + } + + if (liquidationVars.totalDebtValueBefore < Constants.DUST_LIQUIDATION_THRESHOLD) { + assertEq(violatorDebtVarsAfter.debt.owed, 0, HSPOST_SP_LIQ_C); + } + + assertGe(liquidationVars.debtToCover, liquidationVars.debtLiquidated, HSPOST_SP_LIQ_D); + + assertLt( + _userAccountDataVarsBefore(liquidationVars.spoke, liquidationVars.violator) + .data + .healthFactor, + Constants.HEALTH_FACTOR_LIQUIDATION_THRESHOLD, + HSPOST_SP_LIQ_E + ); + + if (violatorDebtVarsAfter.debt.owed > 0) { + assertGt( + _userAccountDataVarsAfter(liquidationVars.spoke, liquidationVars.violator) + .data + .healthFactor, + _userAccountDataVarsBefore(liquidationVars.spoke, liquidationVars.violator) + .data + .healthFactor, + HSPOST_SP_LIQ_G + ); + } + } else { + vm.assume(false); + } + } + + function setUsingAsCollateral(bool usingAsCollateral, uint8 i, uint8 j) external setup { + address onBehalfOf = address(actor); + address spoke = _getRandomSpoke(i); + uint256 reserveId = _getRandomReserveId(spoke, j); + + (bool isUsingAsCollateral, ) = ISpoke(spoke).getUserReserveStatus(reserveId, onBehalfOf); + vm.assume(usingAsCollateral != isUsingAsCollateral); // usingAsCollateral is a noop + + // register user to check post conditions + /// @dev setUsingAsCollateral(reserveId, FALSE) all reserves in user position should be refreshed, + /// so we check all reserves in user position + /// setUsingAsCollateral(reserveId, TRUE) only reserveId in user position should be refreshed, + /// so we check only the reserveId in user position + _registerUserToCheck(spoke, (usingAsCollateral ? reserveId : CHECK_ALL_RESERVES), onBehalfOf); + + _before(); + (bool ok, ) = actor.proxy( + spoke, + abi.encodeCall(ISpoke.setUsingAsCollateral, (reserveId, usingAsCollateral, onBehalfOf)) + ); + + if (ok) { + _after(); + } else { + vm.assume(false); + } + } + + function updateUserRiskPremium(uint8 i) external setup { + address onBehalfOf = address(actor); + address spoke = _getRandomSpoke(i); + _registerUserToCheck(spoke, CHECK_ALL_RESERVES, onBehalfOf); // register user to check post conditions + + _before(); + (bool ok, ) = actor.proxy(spoke, abi.encodeCall(ISpoke.updateUserRiskPremium, (onBehalfOf))); + + if (ok) { + _after(); + /// HSPOST /// + uint256 reserveCount = ISpoke(spoke).getReserveCount(); + for (uint256 j; j < reserveCount; j++) { + UserVars memory varsBefore = _userVarsBefore(spoke, j, onBehalfOf); + UserVars memory varsAfter = _userVarsAfter(spoke, j, onBehalfOf); + assertEq(varsBefore.debt.premiumRay, varsAfter.debt.premiumRay, HSPOST_HUB_M); + assertEq(varsBefore.debt.owed, varsAfter.debt.owed, HSPOST_SP_F); + } + } else { + vm.assume(false); + } + } + + function updateUserDynamicConfig(uint8 i) external setup { + address onBehalfOf = address(actor); + address spoke = _getRandomSpoke(i); + + _registerUserToCheck(spoke, CHECK_ALL_RESERVES, onBehalfOf); + + _before(); + (bool ok, ) = actor.proxy(spoke, abi.encodeCall(ISpoke.updateUserDynamicConfig, (onBehalfOf))); + + if (ok) { + _after(); + assertTrue(_isHealthy(spoke, onBehalfOf), HSPOST_SP_I); + } else { + vm.assume(false); + } + } + + function setUserPositionManager(bool approve, uint8 i, uint8 j) external setup { + address spoke = _getRandomSpoke(i); + address positionManager = _getRandomActor(j); + + _before(); + (bool ok, ) = actor.proxy( + spoke, + abi.encodeCall(ISpoke.setUserPositionManager, (positionManager, approve)) + ); + + if (ok) { + _after(); + } else { + vm.assume(false); + } + } + + // todo check decoded ret + + /////////////////////////////////////////////////////////////////////////////////////////////// + // OWNER ACTIONS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// +} diff --git a/invariants/protocol-suite/handlers/spoke/TreasurySpokeHandler.t.sol b/invariants/protocol-suite/handlers/spoke/TreasurySpokeHandler.t.sol new file mode 100644 index 000000000..3e0bba863 --- /dev/null +++ b/invariants/protocol-suite/handlers/spoke/TreasurySpokeHandler.t.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +// Interfaces +import {ITreasurySpokeHandler} from '../interfaces/ITreasurySpokeHandler.sol'; +import {ITreasurySpoke} from 'src/spoke/interfaces/ITreasurySpoke.sol'; +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; + +// Test Contracts +import {BaseHandler} from '../../base/BaseHandler.t.sol'; + +/// @title TreasurySpokeHandler +/// @notice Handler test contract for a set of actions +contract TreasurySpokeHandler is BaseHandler, ITreasurySpokeHandler { + /////////////////////////////////////////////////////////////////////////////////////////////// + // STATE VARIABLES // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /////////////////////////////////////////////////////////////////////////////////////////////// + // ACTIONS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /////////////////////////////////////////////////////////////////////////////////////////////// + // OWNER ACTIONS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function supply(uint256 amount, uint8 i, uint8 j) external { + address hub = _getRandomHub(i); + address spoke = hubInfo[hub].treasurySpoke; + uint256 reserveId = _getRandomReserveId(spoke, j); + _tryMintAndApprove(_underlying(spoke, reserveId), address(this), spoke, amount); + + _before(); + try ISpoke(spoke).supply(reserveId, amount, msg.sender) { + _after(); + } catch { + vm.assume(false); + } + } + + function withdraw(uint256 amount, uint8 i, uint8 j) external { + address hub = _getRandomHub(i); + address spoke = hubInfo[hub].treasurySpoke; + uint256 reserveId = _getRandomReserveId(spoke, j); + + _before(); + try ISpoke(spoke).withdraw(reserveId, amount, msg.sender) { + _after(); + } catch { + vm.assume(false); + } + } + + function transfer(uint256 amount, uint8 i, uint8 j, uint8 k) external { + address hub = _getRandomHub(i); + address asset = _getRandomBaseAsset(j); + address to = _getRandomActor(k); + + _before(); + try ITreasurySpoke(hubInfo[hub].treasurySpoke).transfer(asset, to, amount) { + _after(); + } catch { + vm.assume(false); + } + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// +} diff --git a/invariants/protocol-suite/hooks/DefaultBeforeAfterHooks.t.sol b/invariants/protocol-suite/hooks/DefaultBeforeAfterHooks.t.sol new file mode 100644 index 000000000..fd0c34a84 --- /dev/null +++ b/invariants/protocol-suite/hooks/DefaultBeforeAfterHooks.t.sol @@ -0,0 +1,458 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +// Libraries +import {EnumerableSet} from 'src/dependencies/openzeppelin/EnumerableSet.sol'; +import {SharesMath} from 'src/hub/libraries/SharesMath.sol'; +import {MathUtils} from 'src/libraries/math/MathUtils.sol'; +import {PercentageMath} from 'src/libraries/math/PercentageMath.sol'; +import {WadRayMath} from 'src/libraries/math/WadRayMath.sol'; + +// Utils +import {Constants} from 'tests/Constants.sol'; + +// Interfaces +import {ISpokeHandler} from '../handlers/interfaces/ISpokeHandler.sol'; +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; +import {IHub} from 'src/hub/interfaces/IHub.sol'; +import {IAssetInterestRateStrategy} from 'src/hub/interfaces/IAssetInterestRateStrategy.sol'; + +// Contracts +import {BaseHooks} from '../base/BaseHooks.t.sol'; + +/// @title DefaultBeforeAfterHooks +/// @notice Helper contract for before and after hooks, state variable caching and postconditions +/// @dev This contract is inherited by handlers +abstract contract DefaultBeforeAfterHooks is BaseHooks { + using WadRayMath for *; + using EnumerableSet for EnumerableSet.AddressSet; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // STRUCTS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + struct Debt { + uint256 drawn; + uint256 premiumRay; + uint256 owed; + } + + struct AssetVars { + IHub.Asset asset; + uint256 drawnRate; + uint256 drawnIndex; + uint256 totalAssets; + uint256 totalShares; + Debt debt; + } + + struct UserVars { + ISpoke.UserPosition position; + Debt debt; + bool collateral; + bool borrowing; + } + + struct UserAccountDataVars { + ISpoke.UserAccountData data; + } + + struct SpokeVars { + IHub.SpokeData spokeData; + uint256 addedAssets; + uint256 addedShares; + Debt debt; + } + + struct DefaultVars { + mapping(address hub => mapping(uint256 assetId => AssetVars)) assetVars; + mapping(address hub => mapping(uint256 assetId => mapping(address spoke => SpokeVars))) spokeVars; + mapping(address spoke => mapping(uint256 reserveId => mapping(address user => UserVars))) userVars; + mapping(address spoke => mapping(address user => UserAccountDataVars)) userAccountDataVars; + } + + struct UserInfo { + address spoke; + uint256 reserveId; + address user; + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HOOKS STORAGE // + /////////////////////////////////////////////////////////////////////////////////////////////// + + // Default variables before and after + DefaultVars defaultVarsBefore; + DefaultVars defaultVarsAfter; + + // Temp array of users to check postconditions for, reset after each handler on _resetState + UserInfo[] usersToCheck; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // SETUP // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Default hooks setup + function _setUpDefaultHooks() internal {} + + /// @notice Helper to initialize storage arrays of default vars + function _setUpDefaultVars(DefaultVars storage _defaultVars) internal {} + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HOOKS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function _defaultHooksBefore() internal { + // Asset values + _setAssetValues(defaultVarsBefore); + // Spoke asset values + _setSpokeAssetValues(defaultVarsBefore); + // User values + _setUserValues(defaultVarsBefore); + } + + function _defaultHooksAfter() internal { + // Asset values + _setAssetValues(defaultVarsAfter); + // Spoke asset values + _setSpokeAssetValues(defaultVarsAfter); + // User values + _setUserValues(defaultVarsAfter); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @dev Only use these helpers in postConditions, do NOT rely on them at Invariants because they not be populated + /// when the fuzzer has updated env (eg block.timestamp) which does not invoke any Handler + + function _setAssetValues(DefaultVars storage defaultVars) internal { + for (uint256 i; i < hubs.length(); i++) { + IHub hub = IHub(hubs.at(i)); + uint256 assetCount = hub.getAssetCount(); + for (uint256 j; j < assetCount; j++) { + (uint256 drawn, ) = hub.getAssetOwed(j); + uint256 premiumRay = hub.getAssetPremiumRay(j); + defaultVars.assetVars[address(hub)][j] = AssetVars({ + asset: hub.getAsset(j), + drawnRate: hub.getAssetDrawnRate(j), + drawnIndex: hub.getAssetDrawnIndex(j), + totalAssets: hub.getAddedAssets(j), + totalShares: hub.getAddedShares(j), + debt: Debt({drawn: drawn, premiumRay: premiumRay, owed: drawn + premiumRay.fromRayUp()}) + }); + } + } + } + + function _setSpokeAssetValues(DefaultVars storage defaultVars) internal { + for (uint256 i; i < hubs.length(); i++) { + IHub hub = IHub(hubs.at(i)); + uint256 assetCount = hub.getAssetCount(); + for (uint256 j; j < assetCount; j++) { + for (uint256 k; k < allSpokes.length; k++) { + address spoke = allSpokes[k]; + (uint256 drawn, ) = hub.getSpokeOwed(j, spoke); + uint256 premiumRay = hub.getSpokePremiumRay(j, spoke); + defaultVars.spokeVars[address(hub)][j][spoke] = SpokeVars({ + spokeData: hub.getSpoke(j, spoke), + addedAssets: hub.getSpokeAddedAssets(j, spoke), + addedShares: hub.getSpokeAddedShares(j, spoke), + debt: Debt({drawn: drawn, premiumRay: premiumRay, owed: drawn + premiumRay.fromRayUp()}) + }); + } + } + } + } + + function _setUserValues(DefaultVars storage defaultVars) internal { + for (uint256 i; i < usersToCheck.length; ++i) { + UserInfo memory userInfo = usersToCheck[i]; + ISpoke spoke = ISpoke(userInfo.spoke); + address user = userInfo.spoke; + defaultVars.userAccountDataVars[userInfo.spoke][userInfo.user].data = spoke + .getUserAccountData(userInfo.user); + + // Cache values for all reserves of the spoke, used after actions: updateUserRiskPremium, updateUserDynamicConfig + if (userInfo.reserveId == CHECK_ALL_RESERVES) { + uint256 reserveCount = spoke.getReserveCount(); + for (uint256 j; j < reserveCount; ++j) { + _setUserVars(defaultVars, spoke, j, user); + } + } else { + // Cache values for a specific reserve of the spoke, used after actions: supply, withdraw, borrow, repay, setUsingAsCollateral + _setUserVars(defaultVars, spoke, userInfo.reserveId, user); + } + } + } + + function _setUserVars( + DefaultVars storage defaultVars, + ISpoke spoke, + uint256 reserveId, + address user + ) internal { + (uint256 drawn, ) = spoke.getUserDebt(reserveId, user); + uint256 premiumRay = spoke.getUserPremiumDebtRay(reserveId, user); + (bool collateral, bool borrowing) = spoke.getUserReserveStatus(reserveId, user); + defaultVars.userVars[address(spoke)][reserveId][user] = UserVars({ + position: spoke.getUserPosition(reserveId, user), + debt: Debt({drawn: drawn, premiumRay: premiumRay, owed: drawn + premiumRay.fromRayUp()}), + collateral: collateral, + borrowing: borrowing + }); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // POST CONDITIONS: HUB // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function assert_GPOST_HUB_A(address hubAddress, uint256 assetId) internal { + AssetVars memory varsBefore = _assetVarsBefore(hubAddress, assetId); + AssetVars memory varsAfter = _assetVarsAfter(hubAddress, assetId); + assertGe(varsAfter.drawnIndex, varsBefore.drawnIndex, GPOST_HUB_A); + } + + function assert_GPOST_HUB_B(address hubAddress, uint256 assetId) internal { + AssetVars memory varsBefore = _assetVarsBefore(hubAddress, assetId); + AssetVars memory varsAfter = _assetVarsAfter(hubAddress, assetId); + + assertFullMulGe( + varsAfter.totalAssets + SharesMath.VIRTUAL_ASSETS, + varsBefore.totalShares + SharesMath.VIRTUAL_SHARES, + varsBefore.totalAssets + SharesMath.VIRTUAL_ASSETS, + varsAfter.totalShares + SharesMath.VIRTUAL_SHARES, + GPOST_HUB_B + ); + } + + function assert_GPOST_HUB_C(address hubAddress, uint256 assetId) internal { + // Read the cached signature of the current action + bytes4 signature = currentActionSignature; + if ( + signature == ISpokeHandler.supply.selector || + signature == ISpokeHandler.withdraw.selector || + signature == ISpokeHandler.borrow.selector || + signature == ISpokeHandler.repay.selector || + signature == ISpokeHandler.updateUserRiskPremium.selector || + signature == ISpokeHandler.liquidationCall.selector + ) { + AssetVars memory vars = _assetVarsAfter(hubAddress, assetId); + assertEq( + vars.drawnRate, + IAssetInterestRateStrategy(hubInfo[hubAddress].irStrategy).calculateInterestRate( + assetId, + vars.asset.liquidity, + vars.debt.drawn, + vars.asset.deficitRay.fromRayUp(), + vars.asset.swept + ), + GPOST_HUB_C + ); + } + } + + function assert_GPOST_HUB_D(address hubAddress, uint256 assetId) internal { + assertLe( + _assetVarsAfter(hubAddress, assetId).asset.lastUpdateTimestamp, + block.timestamp, + GPOST_HUB_D + ); + } + + function assert_GPOST_HUB_EF(address hubAddress, uint256 assetId, address spoke) internal { + // Get the spoke config + IHub.SpokeConfig memory spokeConfig = IHub(hubAddress).getSpokeConfig(assetId, spoke); + (, uint8 decimals) = IHub(hubAddress).getAssetUnderlyingAndDecimals(assetId); + + SpokeVars memory spokeDataBefore = _spokeVarsBefore(hubAddress, assetId, spoke); + SpokeVars memory spokeDataAfter = _spokeVarsAfter(hubAddress, assetId, spoke); + + // GPOST_HUB_E: spoke-level addedAssets must be within addCap after an add action + if ( + spokeDataAfter.addedAssets > spokeDataBefore.addedAssets && + spokeDataAfter.addedShares != spokeDataBefore.addedShares /// @dev required to avoid interest accrual detection + ) { + if (spokeConfig.addCap != MAX_ALLOWED_SPOKE_CAP) { + assertLe( + spokeDataAfter.addedAssets, + spokeConfig.addCap * MathUtils.uncheckedExp(10, decimals), + GPOST_HUB_E + ); + } + } + + // GPOST_HUB_F: spoke-level owed must be within drawCap after a draw action + if (spokeDataAfter.debt.owed > spokeDataBefore.debt.owed) { + if (spokeConfig.drawCap != MAX_ALLOWED_SPOKE_CAP) { + assertLe( + spokeDataAfter.debt.owed + spokeDataAfter.spokeData.deficitRay.fromRayUp(), + spokeConfig.drawCap * MathUtils.uncheckedExp(10, decimals), + GPOST_HUB_F + ); + } + } + } + + function assert_GPOST_HUB_G(address hubAddress, uint256 assetId) internal { + assertGe( + _assetVarsAfter(hubAddress, assetId).asset.lastUpdateTimestamp, + _assetVarsBefore(hubAddress, assetId).asset.lastUpdateTimestamp, + GPOST_HUB_G + ); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // POST CONDITIONS: SPOKE // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function assert_GPOST_SP_A(address spoke, uint256 reserveId, address user) internal { + ISpoke.UserPosition memory userPosition = ISpoke(spoke).getUserPosition(reserveId, user); + uint256 userRiskPremium = ISpoke(spoke).getUserAccountData(user).riskPremium; + + uint256 expected = PercentageMath.percentMulUp(userPosition.drawnShares, userRiskPremium); + assertEq(userPosition.premiumShares, expected, GPOST_SP_A); + } + + function assert_GPOST_SP_B(address spoke, uint256 reserveId, address user) internal { + UserVars memory userVarsBefore = _userVarsBefore(spoke, reserveId, user); + UserVars memory userVarsAfter = _userVarsAfter(spoke, reserveId, user); + + if (userVarsAfter.debt.premiumRay < userVarsBefore.debt.premiumRay) { + assertTrue( + currentActionSignature == ISpokeHandler.repay.selector || + currentActionSignature == ISpokeHandler.liquidationCall.selector, + GPOST_SP_B + ); + } + + if (userVarsAfter.debt.drawn < userVarsBefore.debt.drawn) { + assertTrue( + currentActionSignature == ISpokeHandler.repay.selector || + currentActionSignature == ISpokeHandler.liquidationCall.selector, + GPOST_SP_B2 + ); + assertEq(userVarsAfter.debt.premiumRay, 0, GPOST_SP_B2); + } + } + + function assert_GPOST_SP_E(address spoke, uint256 reserveId, address user) internal { + uint32 latestKey = ISpoke(spoke).getReserve(reserveId).dynamicConfigKey; + uint32 userKey = ISpoke(spoke).getUserPosition(reserveId, user).dynamicConfigKey; + bytes4 signature = currentActionSignature; + + if ( + signature == ISpokeHandler.borrow.selector || + signature == ISpokeHandler.withdraw.selector || + signature == ISpokeHandler.setUsingAsCollateral.selector || + signature == ISpokeHandler.updateUserDynamicConfig.selector + ) { + if (_userVarsBefore(spoke, reserveId, user).collateral) { + assertEq(latestKey, userKey, GPOST_SP_E); + } + } + } + + function assert_GPOST_LIQ_G(address spoke, address user) internal { + UserAccountDataVars memory dataBefore = _userAccountDataVarsBefore(spoke, user); + UserAccountDataVars memory dataAfter = _userAccountDataVarsAfter(spoke, user); + + bytes4 signature = currentActionSignature; + + if ( + dataBefore.data.healthFactor < Constants.HEALTH_FACTOR_LIQUIDATION_THRESHOLD && + dataAfter.data.healthFactor < dataBefore.data.healthFactor + ) { + assertTrue(signature == ISpokeHandler.liquidationCall.selector, GPOST_SP_LIQ_G); + } + } + + function assert_GPOST_SP_LIQ_H(address spoke, address user) internal { + if ( + _userAccountDataVarsAfter(spoke, user).data.healthFactor < + Constants.HEALTH_FACTOR_LIQUIDATION_THRESHOLD + ) { + assertTrue( + currentActionSignature == ISpokeHandler.supply.selector || + currentActionSignature == ISpokeHandler.repay.selector || + currentActionSignature == ISpokeHandler.liquidationCall.selector || + currentActionSignature == ISpokeHandler.updateUserRiskPremium.selector, + GPOST_SP_LIQ_H + ); + } + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function _registerUserToCheck(address spoke, uint256 reserveId, address user) internal { + usersToCheck.push(UserInfo(spoke, reserveId, user)); + } + + function _cacheCurrentActionSignature() internal { + currentActionSignature = bytes4(msg.sig); + } + + function _assetVarsBefore( + address hubAddress, + uint256 assetId + ) internal view returns (AssetVars memory) { + return defaultVarsBefore.assetVars[hubAddress][assetId]; + } + + function _assetVarsAfter( + address hubAddress, + uint256 assetId + ) internal view returns (AssetVars memory) { + return defaultVarsAfter.assetVars[hubAddress][assetId]; + } + + function _spokeVarsBefore( + address hubAddress, + uint256 assetId, + address spoke + ) internal view returns (SpokeVars memory) { + return defaultVarsBefore.spokeVars[hubAddress][assetId][spoke]; + } + + function _spokeVarsAfter( + address hubAddress, + uint256 assetId, + address spoke + ) internal view returns (SpokeVars memory) { + return defaultVarsAfter.spokeVars[hubAddress][assetId][spoke]; + } + + function _userVarsBefore( + address spoke, + uint256 reserveId, + address user + ) internal view returns (UserVars memory) { + return defaultVarsBefore.userVars[spoke][reserveId][user]; + } + + function _userVarsAfter( + address spoke, + uint256 reserveId, + address user + ) internal view returns (UserVars memory) { + return defaultVarsAfter.userVars[spoke][reserveId][user]; + } + + function _userAccountDataVarsBefore( + address spoke, + address user + ) internal view returns (UserAccountDataVars memory) { + return defaultVarsBefore.userAccountDataVars[spoke][user]; + } + + function _userAccountDataVarsAfter( + address spoke, + address user + ) internal view returns (UserAccountDataVars memory) { + return defaultVarsAfter.userAccountDataVars[spoke][user]; + } +} diff --git a/invariants/protocol-suite/hooks/HookAggregator.t.sol b/invariants/protocol-suite/hooks/HookAggregator.t.sol new file mode 100644 index 000000000..f145e0b74 --- /dev/null +++ b/invariants/protocol-suite/hooks/HookAggregator.t.sol @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; +import {DefaultBeforeAfterHooks} from './DefaultBeforeAfterHooks.t.sol'; + +// Utils +import {ErrorHandlers} from '../../shared/utils/ErrorHandlers.sol'; + +/// @title HookAggregator +/// @notice Helper contract to aggregate all before / after hook contracts, inherited on each handler +abstract contract HookAggregator is DefaultBeforeAfterHooks { + /////////////////////////////////////////////////////////////////////////////////////////////// + // SETUP // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Initializer for the hooks + function _setUpHooks() internal { + _setUpDefaultHooks(); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HOOKS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Before hook for the handlers + function _before() internal { + _defaultHooksBefore(); + } + + /// @notice After hook for the handlers + function _after() internal { + _defaultHooksAfter(); + + // POST-CONDITIONS + _checkPostConditions(); + + // Reset the state + _resetState(); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // POSTCONDITION CHECKS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Postconditions for the handlers + function _checkPostConditions() internal { + // Store the message signature to avoid losing it inside the checkPostConditions call context + _cacheCurrentActionSignature(); + + try this.checkPostConditions() {} catch (bytes memory ret) { + ErrorHandlers.handleAssertionError(false, ret, true, GPOST_CHECK_FAILED); + } + } + + /// @dev postconditions checks entrypoint, should be self-called + function checkPostConditions() external { + // Hub postconditions + _hubPostConditions(); + // Spoke postconditions + _spokePostConditions(); + } + + function _hubPostConditions() internal { + // Iterate through all users to check + for (uint256 i; i < usersToCheck.length; i++) { + address spoke = usersToCheck[i].spoke; + uint256 reserveId = usersToCheck[i].reserveId; + + // CHECK_ALL_RESERVES actions should check all reserves for that spoke + if (reserveId == CHECK_ALL_RESERVES) { + uint256 reserveCount = ISpoke(spoke).getReserveCount(); + for (uint256 j; j < reserveCount; j++) { + uint256 assetId = _getAssetId(spoke, j); + address hub = _getHubAddress(spoke, j); + + assert_GPOST_HUB_A(hub, assetId); + assert_GPOST_HUB_B(hub, assetId); + assert_GPOST_HUB_C(hub, assetId); + assert_GPOST_HUB_D(hub, assetId); + assert_GPOST_HUB_EF(hub, assetId, spoke); + assert_GPOST_HUB_G(hub, assetId); + } + } else { + uint256 assetId = _getAssetId(spoke, reserveId); + address hub = _getHubAddress(spoke, reserveId); + + assert_GPOST_HUB_A(hub, assetId); + assert_GPOST_HUB_B(hub, assetId); + assert_GPOST_HUB_C(hub, assetId); + assert_GPOST_HUB_D(hub, assetId); + assert_GPOST_HUB_EF(hub, assetId, spoke); + assert_GPOST_HUB_G(hub, assetId); + } + } + } + + function _spokePostConditions() internal { + // Iterate through all users to check + for (uint256 i; i < usersToCheck.length; i++) { + address spoke = usersToCheck[i].spoke; + uint256 reserveId = usersToCheck[i].reserveId; + address user = usersToCheck[i].user; + + // Check properties for the spoke + assert_GPOST_LIQ_G(spoke, user); + assert_GPOST_SP_LIQ_H(spoke, user); + + // Check properties for all reserves of the spoke, used after actions: updateUserRiskPremium, updateUserDynamicConfig + if (reserveId == CHECK_ALL_RESERVES) { + uint256 reserveCount = ISpoke(spoke).getReserveCount(); + for (uint256 j; j < reserveCount; j++) { + assert_GPOST_SP_A(spoke, j, user); + assert_GPOST_SP_B(spoke, j, user); + assert_GPOST_SP_E(spoke, j, user); + } + } else { + // Check properties for a specific reserve of the spoke, used after actions: supply, withdraw, borrow, repay, setUsingAsCollateral + assert_GPOST_SP_A(spoke, reserveId, user); + assert_GPOST_SP_B(spoke, reserveId, user); + assert_GPOST_SP_E(spoke, reserveId, user); + } + } + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Resets the state of the handlers + function _resetState() internal { + delete usersToCheck; + delete currentActionSignature; + } +} diff --git a/invariants/protocol-suite/invariants/SpokeInvariants.t.sol b/invariants/protocol-suite/invariants/SpokeInvariants.t.sol new file mode 100644 index 000000000..d7ce938c6 --- /dev/null +++ b/invariants/protocol-suite/invariants/SpokeInvariants.t.sol @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import {EnumerableSet} from 'src/dependencies/openzeppelin/EnumerableSet.sol'; + +// Interfaces +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; +import {IHub} from 'src/hub/interfaces/IHub.sol'; + +// Contracts +import {HandlerAggregator} from '../HandlerAggregator.t.sol'; + +/// @title SpokeInvariants +/// @notice Implements Spoke Invariants for the protocol +/// @dev Inherits HandlerAggregator to check actions in assertion testing mode +abstract contract SpokeInvariants is HandlerAggregator { + using EnumerableSet for EnumerableSet.AddressSet; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // SPOKE // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function assert_INV_SP_A(ISpoke spoke, uint256 reserveId) internal { + // Get the assetId related to the reserveId of the spoke + uint256 assetId = _getAssetId(address(spoke), reserveId); + + IHub hub = IHub(_getHubAddress(address(spoke), reserveId)); + + // supply + assertEq( + spoke.getReserveSuppliedShares(reserveId), + hub.getSpokeAddedShares(assetId, address(spoke)), + INV_SP_A + ); + assertEq( + spoke.getReserveSuppliedAssets(reserveId), + hub.getSpokeAddedAssets(assetId, address(spoke)), + INV_SP_A + ); + + // debt + (uint256 d1, uint256 p1) = hub.getSpokeOwed(assetId, address(spoke)); + (uint256 d2, uint256 p2) = spoke.getReserveDebt(reserveId); + assertEq(d2, d1, INV_SP_A); + assertEq(p2, p1, INV_SP_A); + } + + function assert_INV_SP_B(ISpoke spoke, uint256 reserveId, address user) internal { + // reserve supply + if (spoke.getReserveSuppliedAssets(reserveId) > 0) { + assertGt(spoke.getReserveSuppliedShares(reserveId), 0, INV_SP_B); + } + // reserve debt + if (spoke.getReserveTotalDebt(reserveId) > 0) { + assertGt( + IHub(_getHubAddress(address(spoke), reserveId)).getSpokeDrawnShares( + _getAssetId(address(spoke), reserveId), + address(spoke) + ), + 0, + INV_SP_B + ); + } + // user supply + if (spoke.getUserSuppliedAssets(reserveId, user) > 0) { + assertGt(spoke.getUserSuppliedShares(reserveId, user), 0, INV_SP_B); + } + // user debt + if (spoke.getUserTotalDebt(reserveId, user) > 0) { + ISpoke.UserPosition memory up = spoke.getUserPosition(reserveId, user); + assertTrue(up.drawnShares > 0 || up.premiumShares > 0, INV_SP_B); + } + } + + function assert_INV_SP_C(ISpoke spoke, uint256 reserveId) internal { + uint256 sumSpokeDebts; + for (uint256 i; i < actors.length(); i++) { + sumSpokeDebts += spoke.getUserTotalDebt(reserveId, actors.at(i)); + } + assertGe(sumSpokeDebts, spoke.getReserveTotalDebt(reserveId), INV_SP_C); + } + + function assert_INV_SP_D(ISpoke spoke, address user) internal { + ISpoke.UserAccountData memory d = spoke.getUserAccountData(user); + if (d.totalDebtValueRay == 0) { + assertEq(d.healthFactor, type(uint256).max, INV_SP_D); + } else if (d.totalCollateralValue == 0) { + assertEq(d.healthFactor, 0, INV_SP_D); + } + } + + function assert_INV_SP_E(ISpoke spoke, uint256 reserveId) internal { + uint256 sumUserShares; + for (uint256 i; i < actors.length(); i++) { + sumUserShares += spoke.getUserSuppliedShares(reserveId, actors.at(i)); + } + assertEq(sumUserShares, spoke.getReserveSuppliedShares(reserveId), INV_SP_E); + } + + function assert_INV_SP_F(ISpoke spoke, uint256 reserveId) internal { + uint256 sumUserAssets; + for (uint256 i; i < actors.length(); i++) { + sumUserAssets += spoke.getUserSuppliedAssets(reserveId, actors.at(i)); + } + uint256 reserveSuppliedAssets = spoke.getReserveSuppliedAssets(reserveId); + assertLe(sumUserAssets, reserveSuppliedAssets, INV_SP_F); + assertApproxEqAbs(sumUserAssets, reserveSuppliedAssets, NUMBER_OF_ACTORS, INV_SP_F); + } + + function assert_INV_SP_H(ISpoke spoke, uint256 reserveId, address user) internal { + uint32 userKey = spoke.getUserPosition(reserveId, user).dynamicConfigKey; + uint32 reserveKey = spoke.getReserve(reserveId).dynamicConfigKey; + if (userKey > 0) { + assertLe(uint256(userKey), uint256(reserveKey), INV_SP_H); + } + } + + function assert_INV_SP_I(ISpoke spoke, uint256 reserveId, address user) internal { + ISpoke.UserPosition memory up = spoke.getUserPosition(reserveId, user); + if (up.drawnShares == 0) { + assertEq(up.premiumShares, 0, INV_SP_I); + assertEq(up.premiumOffsetRay, 0, INV_SP_I); + } + } +} diff --git a/invariants/protocol-suite/replays/ReplayTest_1.t.sol b/invariants/protocol-suite/replays/ReplayTest_1.t.sol new file mode 100644 index 000000000..d088905ec --- /dev/null +++ b/invariants/protocol-suite/replays/ReplayTest_1.t.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +// Libraries +import 'forge-std/Test.sol'; +import 'forge-std/console.sol'; + +// Contracts +import {Invariants} from '../Invariants.t.sol'; +import {Setup} from '../Setup.t.sol'; + +// Utils +import {Actor} from '../../shared/utils/Actor.sol'; + +contract ReplayTest1 is Invariants, Setup { + // Generated from Echidna reproducers + + // Target contract instance (you may need to adjust this) + ReplayTest1 Tester = this; + + modifier setup() override { + _; + } + + function setUp() public { + // Deploy protocol contracts + _setUp(); + + /// @dev fixes the actor to the first user + actor = userToActor[USER1]; + + vm.warp(101007); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // REPLAY TESTS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function test_replay_1_supply() public { + _setUpActor(USER3); + _delay(140400); + Tester.supply(7, 242, 154, 0); + _delay(543845); + Tester.setUsingAsCollateral(true, 74, 212); + _delay(527372); + Tester.borrow(2, 218, 0, 0); + _delay(116349); + Tester.supply(8, 128, 2, 0); + } + + function test_replay_1_repay() public { + _setUpActor(USER3); + _delay(140400); + Tester.supply(10388, 95, 174, 0); + _delay(543845); + Tester.setUsingAsCollateral(true, 0, 52); + _delay(527372); + Tester.borrow(8603, 248, 142, 0); + _delay(243334); + Tester.repay(1, 116, 254, 252); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Fast forward the time and set up an actor, + /// @dev Use for ECHIDNA call-traces + function _delay(uint256 _seconds) internal { + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up an actor + function _setUpActor(address _origin) internal { + actor = userToActor[_origin]; + } + + /// @notice Set up an actor and fast forward the time + /// @dev Use for ECHIDNA call-traces + function _setUpActorAndDelay(address _origin, uint256 _seconds) internal { + actor = userToActor[_origin]; + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up a specific block and actor + function _setUpBlockAndActor(uint256 _block, address _user) internal { + vm.roll(_block); + actor = userToActor[_user]; + } + + /// @notice Set up a specific timestamp and actor + function _setUpTimestampAndActor(uint256 _timestamp, address _user) internal { + vm.warp(_timestamp); + actor = userToActor[_user]; + } +} diff --git a/invariants/protocol-suite/replays/ReplayTest_2.t.sol b/invariants/protocol-suite/replays/ReplayTest_2.t.sol new file mode 100644 index 000000000..eb082ba6e --- /dev/null +++ b/invariants/protocol-suite/replays/ReplayTest_2.t.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +// Libraries +import 'forge-std/Test.sol'; +import 'forge-std/console.sol'; + +// Contracts +import {Invariants} from '../Invariants.t.sol'; +import {Setup} from '../Setup.t.sol'; + +// Utils +import {Actor} from '../../shared/utils/Actor.sol'; + +contract ReplayTest2 is Invariants, Setup { + // Generated from Echidna reproducers + + // Target contract instance (you may need to adjust this) + ReplayTest2 Tester = this; + + modifier setup() override { + _; + } + + function setUp() public { + // Deploy protocol contracts + _setUp(); + + /// @dev fixes the actor to the first user + actor = userToActor[USER1]; + + vm.warp(101007); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // REPLAY TESTS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function test_replay_2_INV_HUB_B() public { + _setUpActor(USER3); + _delay(140400); + Tester.supply(16, 176, 48, 0); + _delay(543845); + Tester.setUsingAsCollateral(true, 196, 188); + _delay(527372); + Tester.borrow(1, 80, 98, 60); + _delay(284444); + Tester.updateUserRiskPremium(98); + _delay(52383); + Tester.updateUserRiskPremium(102); + _checkAllHubInvariants(); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Fast forward the time and set up an actor, + /// @dev Use for ECHIDNA call-traces + function _delay(uint256 _seconds) internal { + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up an actor + function _setUpActor(address _origin) internal { + actor = userToActor[_origin]; + } + + /// @notice Set up an actor and fast forward the time + /// @dev Use for ECHIDNA call-traces + function _setUpActorAndDelay(address _origin, uint256 _seconds) internal { + actor = userToActor[_origin]; + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up a specific block and actor + function _setUpBlockAndActor(uint256 _block, address _user) internal { + vm.roll(_block); + actor = userToActor[_user]; + } + + /// @notice Set up a specific timestamp and actor + function _setUpTimestampAndActor(uint256 _timestamp, address _user) internal { + vm.warp(_timestamp); + actor = userToActor[_user]; + } +} diff --git a/invariants/protocol-suite/replays/ReplayTest_3.t.sol b/invariants/protocol-suite/replays/ReplayTest_3.t.sol new file mode 100644 index 000000000..fde4b4236 --- /dev/null +++ b/invariants/protocol-suite/replays/ReplayTest_3.t.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +// Libraries +import 'forge-std/Test.sol'; +import 'forge-std/console.sol'; + +// Contracts +import {Invariants} from '../Invariants.t.sol'; +import {Setup} from '../Setup.t.sol'; + +// Utils +import {Actor} from '../../shared/utils/Actor.sol'; + +contract ReplayTest3 is Invariants, Setup { + // Generated from Echidna reproducers + + // Target contract instance (you may need to adjust this) + ReplayTest3 Tester = this; + + modifier setup() override { + _; + } + + function setUp() public { + // Deploy protocol contracts + _setUp(); + + /// @dev fixes the actor to the first user + actor = userToActor[USER1]; + + vm.warp(101007); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // REPLAY TESTS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function test_replay_3_setUsingAsCollateral() public { + _setUpActor(USER3); + Tester.supply(790, 128, 71, 203); + Tester.setUsingAsCollateral(true, 111, 255); + Tester.borrow(527, 68, 203, 11); + _setUpActor(USER1); + _delay(689004); + Tester.setUsingAsCollateral(false, 15, 15); + _checkAllHubInvariants(); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Fast forward the time and set up an actor, + /// @dev Use for ECHIDNA call-traces + function _delay(uint256 _seconds) internal { + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up an actor + function _setUpActor(address _origin) internal { + actor = userToActor[_origin]; + } + + /// @notice Set up an actor and fast forward the time + /// @dev Use for ECHIDNA call-traces + function _setUpActorAndDelay(address _origin, uint256 _seconds) internal { + actor = userToActor[_origin]; + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up a specific block and actor + function _setUpBlockAndActor(uint256 _block, address _user) internal { + vm.roll(_block); + actor = userToActor[_user]; + } + + /// @notice Set up a specific timestamp and actor + function _setUpTimestampAndActor(uint256 _timestamp, address _user) internal { + vm.warp(_timestamp); + actor = userToActor[_user]; + } +} diff --git a/invariants/protocol-suite/replays/ReplayTest_4.t.sol b/invariants/protocol-suite/replays/ReplayTest_4.t.sol new file mode 100644 index 000000000..cf358757c --- /dev/null +++ b/invariants/protocol-suite/replays/ReplayTest_4.t.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +// Libraries +import 'forge-std/Test.sol'; +import 'forge-std/console.sol'; + +// Contracts +import {Invariants} from '../Invariants.t.sol'; +import {Setup} from '../Setup.t.sol'; + +// Utils +import {Actor} from '../../shared/utils/Actor.sol'; + +contract ReplayTest4 is Invariants, Setup { + // Generated from Echidna reproducers + + // Target contract instance (you may need to adjust this) + ReplayTest4 Tester = this; + + modifier setup() override { + _; + } + + function setUp() public { + // Deploy protocol contracts + _setUp(); + + /// @dev fixes the actor to the first user + actor = userToActor[USER1]; + + vm.warp(101007); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // REPLAY TESTS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function test_replay_4_supply() public { + _setUpActor(USER3); + Tester.supply(790, 128, 71, 203); + Tester.setUsingAsCollateral(true, 111, 255); + Tester.borrow(527, 68, 95, 11); + _setUpActor(USER1); + _delay(434894); + Tester.supply(423, 66, 149, 47); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Fast forward the time and set up an actor, + /// @dev Use for ECHIDNA call-traces + function _delay(uint256 _seconds) internal { + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up an actor + function _setUpActor(address _origin) internal { + actor = userToActor[_origin]; + } + + /// @notice Set up an actor and fast forward the time + /// @dev Use for ECHIDNA call-traces + function _setUpActorAndDelay(address _origin, uint256 _seconds) internal { + actor = userToActor[_origin]; + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up a specific block and actor + function _setUpBlockAndActor(uint256 _block, address _user) internal { + vm.roll(_block); + actor = userToActor[_user]; + } + + /// @notice Set up a specific timestamp and actor + function _setUpTimestampAndActor(uint256 _timestamp, address _user) internal { + vm.warp(_timestamp); + actor = userToActor[_user]; + } +} diff --git a/invariants/protocol-suite/replays/ReplayTest_5.t.sol b/invariants/protocol-suite/replays/ReplayTest_5.t.sol new file mode 100644 index 000000000..43a263414 --- /dev/null +++ b/invariants/protocol-suite/replays/ReplayTest_5.t.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +// Libraries +import 'forge-std/Test.sol'; +import 'forge-std/console.sol'; + +// Contracts +import {Invariants} from '../Invariants.t.sol'; +import {Setup} from '../Setup.t.sol'; + +// Utils +import {Actor} from '../../shared/utils/Actor.sol'; + +contract ReplayTest5 is Invariants, Setup { + // Generated from Echidna reproducers + + // Target contract instance (you may need to adjust this) + ReplayTest5 Tester = this; + + modifier setup() override { + _; + } + + function setUp() public { + // Deploy protocol contracts + _setUp(); + + /// @dev fixes the actor to the first user + actor = userToActor[USER1]; + + vm.warp(101007); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // REPLAY TESTS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Fast forward the time and set up an actor, + /// @dev Use for ECHIDNA call-traces + function _delay(uint256 _seconds) internal { + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up an actor + function _setUpActor(address _origin) internal { + actor = userToActor[_origin]; + } + + /// @notice Set up an actor and fast forward the time + /// @dev Use for ECHIDNA call-traces + function _setUpActorAndDelay(address _origin, uint256 _seconds) internal { + actor = userToActor[_origin]; + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up a specific block and actor + function _setUpBlockAndActor(uint256 _block, address _user) internal { + vm.roll(_block); + actor = userToActor[_user]; + } + + /// @notice Set up a specific timestamp and actor + function _setUpTimestampAndActor(uint256 _timestamp, address _user) internal { + vm.warp(_timestamp); + actor = userToActor[_user]; + } +} diff --git a/invariants/protocol-suite/replays/ReplayTest_6.t.sol b/invariants/protocol-suite/replays/ReplayTest_6.t.sol new file mode 100644 index 000000000..17f41f5fc --- /dev/null +++ b/invariants/protocol-suite/replays/ReplayTest_6.t.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +// Libraries +import 'forge-std/Test.sol'; +import 'forge-std/console.sol'; + +// Contracts +import {Invariants} from '../Invariants.t.sol'; +import {Setup} from '../Setup.t.sol'; + +// Utils +import {Actor} from '../../shared/utils/Actor.sol'; + +contract ReplayTest6 is Invariants, Setup { + // Generated from Echidna reproducers + + // Target contract instance (you may need to adjust this) + ReplayTest6 Tester = this; + + modifier setup() override { + _; + } + + function setUp() public { + // Deploy protocol contracts + _setUp(); + + /// @dev fixes the actor to the first user + actor = userToActor[USER1]; + + vm.warp(101007); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // REPLAY TESTS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function test_replay_6_freezeAllReserves() public { + _setUpActor(USER3); + Tester.supply(673, 128, 1, 203); + Tester.setUsingAsCollateral(true, 111, 255); + _setUpActor(USER1); + _delay(338347); + Tester.freezeAllReserves(32); + _checkAllHubInvariants(); + } + + function test_replay_6_supply() public { + _setUpActor(USER3); + Tester.supply(790, 197, 87, 203); + Tester.setUsingAsCollateral(true, 197, 255); + Tester.borrow(527, 68, 65, 11); + _setUpActor(USER1); + _delay(467); + Tester.supply(1327428228, 3, 151, 99); + _checkAllSpokeInvariants(); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Fast forward the time and set up an actor, + /// @dev Use for ECHIDNA call-traces + function _delay(uint256 _seconds) internal { + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up an actor + function _setUpActor(address _origin) internal { + actor = userToActor[_origin]; + } + + /// @notice Set up an actor and fast forward the time + /// @dev Use for ECHIDNA call-traces + function _setUpActorAndDelay(address _origin, uint256 _seconds) internal { + actor = userToActor[_origin]; + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up a specific block and actor + function _setUpBlockAndActor(uint256 _block, address _user) internal { + vm.roll(_block); + actor = userToActor[_user]; + } + + /// @notice Set up a specific timestamp and actor + function _setUpTimestampAndActor(uint256 _timestamp, address _user) internal { + vm.warp(_timestamp); + actor = userToActor[_user]; + } +} diff --git a/invariants/protocol-suite/replays/ReplayTest_7.t.sol b/invariants/protocol-suite/replays/ReplayTest_7.t.sol new file mode 100644 index 000000000..8d1e441d1 --- /dev/null +++ b/invariants/protocol-suite/replays/ReplayTest_7.t.sol @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +// Libraries +import 'forge-std/Test.sol'; +import 'forge-std/console.sol'; + +// Contracts +import {Invariants} from '../Invariants.t.sol'; +import {Setup} from '../Setup.t.sol'; + +// Utils +import {Actor} from '../../shared/utils/Actor.sol'; + +contract ReplayTest7 is Invariants, Setup { + // Generated from Echidna reproducers + + // Target contract instance (you may need to adjust this) + ReplayTest7 Tester = this; + + modifier setup() override { + _; + } + + function setUp() public { + // Deploy protocol contracts + _setUp(); + + /// @dev fixes the actor to the first user + actor = userToActor[USER1]; + + vm.warp(101007); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // REPLAY TESTS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function test_replay_7_updateUserRiskPremium() public { + _setUpActor(USER3); + Tester.supply(790, 128, 71, 203); + Tester.setUsingAsCollateral(true, 111, 255); + Tester.borrow(527, 68, 203, 11); + _setUpActor(USER1); + _delay(1812425); + _delay(320876); + Tester.updateFrozen(false, 59, 87); + _setUpActor(USER2); + _delay(343393); + Tester.updateHealthFactorForMaxBonus(105, 146); + _setUpActor(USER1); + _delay(2272303); + _setUpActor(USER2); + _delay(62189); + Tester.updateUserRiskPremium(101); + _setUpActor(USER3); + _delay(323286); + Tester.donateUnderlyingToHub(194, 231, 231); + _delay(505810); + Tester.borrow(56, 77, 155, 23); + _setUpActor(USER2); + _delay(112744); + Tester.updateBorrowable(false, 223, 159); + _setUpActor(USER1); + _delay(1632525); + _setUpActor(USER2); + _delay(128066); + Tester.updateFrozen(false, 52, 208); + _delay(180364); + Tester.setPrice( + 44015031536544474472307730349212877645270025151054012889336165188860863984788, + 87 + ); + _setUpActor(USER3); + _delay(460111); + Tester.updateBorrowable(true, 160, 11); + _setUpActor(USER1); + _delay(898801); + _setUpActor(USER3); + _delay(271962); + Tester.pauseAllReserves(251); + _setUpActor(USER2); + _delay(928317); + _setUpActor(USER1); + _delay(23908); + Tester.updateBorrowable(true, 217, 55); + _setUpActor(USER3); + _delay(582766); + Tester.updateUserRiskPremium(25); + _checkAllHubInvariants(); + _checkAllSpokeInvariants(); + } + + function test_replay_7_repay() public { + _setUpActor(USER3); + _delay(321376); + Tester.supply(790, 197, 87, 203); + Tester.supply(100000000000000000000000002, 50, 228, 214); + Tester.setUsingAsCollateral(true, 197, 255); + _delay(20833); + Tester.borrow(2973933138, 65, 107, 12); + _setUpActor(USER1); + Tester.updateSpokeAddCap(172, 180, 253, 253); + _setUpActor(USER3); + _delay(997); + Tester.repay( + 1209722426464509529070304541882533570806727645123886685504166337177518312, + 62, + 253, + 252 + ); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Fast forward the time and set up an actor, + /// @dev Use for ECHIDNA call-traces + function _delay(uint256 _seconds) internal { + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up an actor + function _setUpActor(address _origin) internal { + actor = userToActor[_origin]; + } + + /// @notice Set up an actor and fast forward the time + /// @dev Use for ECHIDNA call-traces + function _setUpActorAndDelay(address _origin, uint256 _seconds) internal { + actor = userToActor[_origin]; + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up a specific block and actor + function _setUpBlockAndActor(uint256 _block, address _user) internal { + vm.roll(_block); + actor = userToActor[_user]; + } + + /// @notice Set up a specific timestamp and actor + function _setUpTimestampAndActor(uint256 _timestamp, address _user) internal { + vm.warp(_timestamp); + actor = userToActor[_user]; + } +} diff --git a/invariants/protocol-suite/replays/ReplayTest_8.t.sol b/invariants/protocol-suite/replays/ReplayTest_8.t.sol new file mode 100644 index 000000000..3255485be --- /dev/null +++ b/invariants/protocol-suite/replays/ReplayTest_8.t.sol @@ -0,0 +1,288 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +// Libraries +import 'forge-std/Test.sol'; +import 'forge-std/console.sol'; + +// Contracts +import {Invariants} from '../Invariants.t.sol'; +import {Setup} from '../Setup.t.sol'; + +// Utils +import {Actor} from '../../shared/utils/Actor.sol'; + +contract ReplayTest7 is Invariants, Setup { + // Generated from Echidna reproducers + + // Target contract instance (you may need to adjust this) + ReplayTest7 Tester = this; + + modifier setup() override { + _; + } + + function setUp() public { + // Deploy protocol contracts + _setUp(); + + /// @dev fixes the actor to the first user + actor = userToActor[USER1]; + + vm.warp(101007); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // REPLAY TESTS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + function test_replay_withdraw() public { + _setUpActor(USER2); + _delay(151486); + Tester.updateUserRiskPremium(127); + _setUpActor(USER3); + _delay(1030565); + Tester.setUsingAsCollateral(true, 0, 0); + _setUpActor(USER2); + _delay(70441); + Tester.updatePaused(true, 0, 152); + _delay(140369); + Tester.updateSpokeRiskPremiumThreshold(0, 13, 20, 75); + Tester.updateUserRiskPremium(156); + _setUpActor(USER1); + _delay(71842); + Tester.updateUserRiskPremium(7); + _setUpActor(USER2); + _delay(3481); + Tester.updateUserDynamicConfig(127); + Tester.updatePaused(true, 118, 165); + _setUpActor(USER1); + Tester.updateUserDynamicConfig(13); + _delay(340229); + Tester.updatePaused(false, 134, 157); + _setUpActor(USER2); + _delay(267059); + Tester.updateFrozen(true, 121, 98); + _delay(70398); + Tester.donateUnderlyingToSpoke(10, 26, 123); + _delay(570296); + Tester.updateSpokeHalted(false, 225, 36, 0); + _setUpActor(USER3); + _delay(40); + Tester.setPrice(528, 24); + _setUpActor(USER2); + Tester.supply(255, 190, 60, 95); + _setUpActor(USER3); + Tester.updateUserDynamicConfig(61); + _delay(28399); + Tester.freezeAllReserves(209); + _delay(352625); + Tester.updateUserRiskPremium(126); + _setUpActor(USER1); + Tester.pauseAllReserves(17); + _setUpActor(USER3); + _delay(585234); + Tester.updateFrozen(true, 190, 214); + _setUpActor(USER2); + _delay(395602); + Tester.updatePaused(false, 211, 9); + _delay(420078); + Tester.setPrice( + 32073901804028723412800996385287954179043683908620921829695310985447157891024, + 32 + ); + _setUpActor(USER1); + Tester.updateUserDynamicConfig(32); + _setUpActor(USER3); + _delay(233249); + Tester.freezeAllReserves(31); + _setUpActor(USER1); + _delay(147712); + Tester.freezeAllReserves(0); + _setUpActor(USER3); + Tester.updatePaused(false, 69, 124); + _setUpActor(USER1); + Tester.updatePaused(false, 204, 59); + _setUpActor(USER2); + _delay(3); + Tester.freezeAllReserves(208); + _setUpActor(USER1); + _delay(563779); + Tester.updateSpokeHalted(false, 24, 223, 0); + _setUpActor(USER3); + Tester.freezeAllReserves(78); + _setUpActor(USER2); + Tester.updateSpokeHalted(true, 144, 0, 21); + _setUpActor(USER1); + Tester.updateUserDynamicConfig(248); + _setUpActor(USER3); + _delay(434606); + Tester.updateSpokeHalted(true, 38, 5, 100); + _setUpActor(USER1); + _delay(260725); + Tester.updateBorrowable(false, 0, 174); + _setUpActor(USER3); + _delay(23626); + Tester.donateUnderlyingToHub( + 57896044618658097711785492504343953926547177528453588222701002057468963542481, + 121, + 149 + ); + _setUpActor(USER1); + Tester.setPrice(-2500, 70); + _setUpActor(USER3); + _delay(207320); + Tester.donateUnderlyingToHub( + 57896044618658097711785492504343953926464851149359812787997104700240680714240, + 0, + 22 + ); + _setUpActor(USER2); + Tester.updateBorrowable(true, 64, 0); + _setUpActor(USER3); + Tester.setPrice( + 13855700271963159636037022216593078218492230319803981905413115251605142940095, + 31 + ); + Tester.updateUserRiskPremium(80); + _setUpActor(USER2); + Tester.updatePaused(true, 104, 80); + _setUpActor(USER3); + _delay(448805); + Tester.freezeAllReserves(90); + _setUpActor(USER2); + Tester.updateFrozen(true, 0, 0); + _delay(432000); + Tester.updateUserDynamicConfig(86); + Tester.updateBorrowable(false, 225, 98); + _setUpActor(USER1); + _delay(333625); + Tester.updateUserRiskPremium(0); + _setUpActor(USER3); + _delay(531501); + Tester.withdraw( + 28019993473610222170674694923366466910776419356246130299415631191766869810267, + 1, + 188, + 147 + ); + } + + function test_replay_withdraw_2() public { + _setUpActor(USER2); + _delay(48); + Tester.supply(5765, 214, 57, 2); + Tester.pauseAllReserves(229); + Tester.withdraw( + 79270905586291627497307400106420653318261579396350751610744730950627405265, + 190, + 189, + 2 + ); + } + + function test_replay_withdraw_3() public { + _setUpActor(USER1); + _delay(48); + Tester.updatePaused(false, 43, 7); + _setUpActor(USER2); + Tester.donateUnderlyingToSpoke( + 4453226477229950780488458817391925832298660130277646469228882959756527864210, + 82, + 35 + ); + Tester.updateBorrowable(true, 102, 225); + Tester.updateUserRiskPremium(232); + _setUpActor(USER3); + Tester.updatePaused(true, 92, 0); + _setUpActor(USER1); + _delay(184974); + Tester.updateFrozen(false, 5, 196); + _setUpActor(USER2); + Tester.setPrice(-1, 56); + _setUpActor(USER1); + Tester.updateUserDynamicConfig(22); + _delay(12646); + Tester.supply(50000000, 0, 27, 2); + _setUpActor(USER3); + _delay(367615); + Tester.pauseAllReserves(137); + _setUpActor(USER1); + _delay(187876); + Tester.updateUserDynamicConfig(35); + Tester.donateUnderlyingToHub( + 15900516933484199723709268303309061460856563576000487395170929283679828504510, + 100, + 0 + ); + _setUpActor(USER2); + Tester.updatePaused(false, 153, 221); + _setUpActor(USER1); + Tester.withdraw( + 34926635329146824736853381233931534329217246589975803687784392641014182723417, + 0, + 201, + 10 + ); + } + + function test_replay_setUsingAsCollateral_1() public { + _setUpActor(USER3); + _delay(48); + Tester.setUsingAsCollateral(true, 0, 42); + _setUpActor(USER1); + Tester.addLiquidationFee( + 13323504814495136016677116177882803507625235598720014455673974911556623963286, + 124, + 0 + ); + _setUpActor(USER3); + Tester.setUsingAsCollateral(false, 200, 146); + } + + function test_updateUserDynamicConfig_1() public { + _setUpActor(USER3); + _delay(56); + Tester.addMaxLiquidationBonus( + 172001014342743600581657909606579704666279210377953032615107995338039342389, + 14, + 0 + ); + _setUpActor(USER1); + Tester.updateUserDynamicConfig(0); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Fast forward the time and set up an actor, + /// @dev Use for ECHIDNA call-traces + function _delay(uint256 _seconds) internal { + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up an actor + function _setUpActor(address _origin) internal { + actor = userToActor[_origin]; + } + + /// @notice Set up an actor and fast forward the time + /// @dev Use for ECHIDNA call-traces + function _setUpActorAndDelay(address _origin, uint256 _seconds) internal { + actor = userToActor[_origin]; + vm.warp(block.timestamp + _seconds); + } + + /// @notice Set up a specific block and actor + function _setUpBlockAndActor(uint256 _block, address _user) internal { + vm.roll(_block); + actor = userToActor[_user]; + } + + /// @notice Set up a specific timestamp and actor + function _setUpTimestampAndActor(uint256 _timestamp, address _user) internal { + vm.warp(_timestamp); + actor = userToActor[_user]; + } +} diff --git a/invariants/protocol-suite/specs/InvariantsSpec.t.sol b/invariants/protocol-suite/specs/InvariantsSpec.t.sol new file mode 100644 index 000000000..6a77926d3 --- /dev/null +++ b/invariants/protocol-suite/specs/InvariantsSpec.t.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import {HubInvariantsSpec} from '../../hub-suite/specs/HubInvariantsSpec.t.sol'; +import {SpokeInvariantsSpec} from './SpokeInvariantsSpec.t.sol'; + +/// @title InvariantsSpec +/// @notice Invariants specification for the protocol +/// @dev Aggregates hub and spoke invariant string. +abstract contract InvariantsSpec is HubInvariantsSpec, SpokeInvariantsSpec {} diff --git a/invariants/protocol-suite/specs/PostconditionsSpec.t.sol b/invariants/protocol-suite/specs/PostconditionsSpec.t.sol new file mode 100644 index 000000000..d6c54f360 --- /dev/null +++ b/invariants/protocol-suite/specs/PostconditionsSpec.t.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import {HubPostconditionsSpec} from '../../hub-suite/specs/HubPostconditionsSpec.t.sol'; + +/// @title PostconditionsSpec +/// @notice Postconditions specification for the protocol +/// @dev Contains spoke postcondition strings. Hub postcondition strings are inherited from HubPostconditionsSpec. +abstract contract PostconditionsSpec is HubPostconditionsSpec { + /////////////////////////////////////////////////////////////////////////////////////////////// + // SPOKE DEBT ORDERING // + /////////////////////////////////////////////////////////////////////////////////////////////// + + string constant GPOST_SP_B = + 'GPOST_SP_B: Premium debt of an individual user can only decrease by calling repay or liquidationCall when premium debt is not zero'; + + string constant GPOST_SP_B2 = + 'GPOST_SP_B2: Drawn debt of an individual user can only decrease by calling repay or liquidationCall and if premium debt is zero after the action'; + + string constant HSPOST_SP_C = 'HSPOST_SP_C: User liability should decrease after repayment'; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // SPOKE RISK // + /////////////////////////////////////////////////////////////////////////////////////////////// + + string constant GPOST_SP_A = + "GPOST_SP_A: Stored (user.premiumDrawnShares/user.baseDrawnShares) & calculated user risk premium (calculation based on user's position, via spoke.calculateUserAccountData) are the same right after an operation"; + + string constant GPOST_SP_E = + 'GPOST_SP_E: DynamicRiskConfiguration for a user position is updated to latest reserve state whenever an action can potentially make their position less healthy'; + // - Updates on: borrow, withdraw, disableAsCollateral, updateUserDynamicConfig. + // - Unchanged on: supply, repay, liquidate, updateUserRiskPremium, setUserPositionManager. + // - Enabling collateral updates only the relevant reserve's dynamic config. + + string constant HSPOST_SP_F = + 'HSPOST_SP_F: Total debt of a user should not change after updateUserRiskPremium'; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // SPOKE SOLVENCY // + /////////////////////////////////////////////////////////////////////////////////////////////// + + string constant HSPOST_SP_D = 'HSPOST_SP_D: Unhealthy user cannot borrow more'; + + string constant GPOST_SP_H = 'GPOST_SP_H: Unhealthy user cannot withdraw active collateral'; + + string constant HSPOST_SP_I = + 'HSPOST_SP_I: User is healthy after borrow/withdraw/updateUserDynamicConfig'; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // SPOKE LIQUIDATION // + /////////////////////////////////////////////////////////////////////////////////////////////// + + string constant HSPOST_SP_LIQ_A = + "HSPOST_SP_LIQ_A: Liquidation cannot result in an amount of liquidated debt > user's total debt position"; + + string constant HSPOST_SP_LIQ_B = + "HSPOST_SP_LIQ_B: Liquidation cannot result in an amount of seized collateral (sold collateral + liquidation bonus) > user's collateral position"; + + string constant HSPOST_SP_LIQ_C = + 'HSPOST_SP_LIQ_C: Liquidator is always forced to repay all the debt of a user if debt value is below DUST_DEBT_LIQUIDATION_THRESHOLD'; + + string constant HSPOST_SP_LIQ_D = + 'HSPOST_SP_LIQ_D: Liquidation cannot result in an amount of liquidated debt > debtToCover'; + + string constant HSPOST_SP_LIQ_E = 'HSPOST_SP_LIQ_E: Only unhealthy user can be liquidated'; + + string constant HSPOST_SP_LIQ_G = + 'HSPOST_SP_LIQ_G: After liquidation, if debt remains, health factor should improve toward target'; + + string constant GPOST_SP_LIQ_G = + 'GPOST_SP_LIQ_G: Only liquidations can deteriorate the health factor of an already unhealthy account'; + + string constant GPOST_SP_LIQ_H = + 'GPOST_SP_LIQ_H: Only a supply, repay, liquidationCall & updateUserRiskPremium can leave an account in an unhealthy state'; +} diff --git a/invariants/protocol-suite/specs/SpokeInvariantsSpec.t.sol b/invariants/protocol-suite/specs/SpokeInvariantsSpec.t.sol new file mode 100644 index 000000000..24ab757f4 --- /dev/null +++ b/invariants/protocol-suite/specs/SpokeInvariantsSpec.t.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +/// @title SpokeInvariantsSpec +/// @notice Invariants specification for the spoke +/// @dev Contains pseudo code and description for the invariant properties in the spoke. +/// This is the canonical source for all spoke invariant strings. +abstract contract SpokeInvariantsSpec { + /////////////////////////////////////////////////////////////////////////////////////////////// + // SYNC // + /////////////////////////////////////////////////////////////////////////////////////////////// + + string constant INV_SP_A = + "INV_SP_A: Spoke reserve accounting should always match hub's spoke accounting on the corresponding registered asset."; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // ACCOUNTING // + /////////////////////////////////////////////////////////////////////////////////////////////// + + string constant INV_SP_B = + 'INV_SP_B: User/Reserve cannot have non-zero assets and zero shares in supply or debt sides.'; + + string constant INV_SP_C = + 'INV_SP_C: Sum of spoke debts on a single asset must be greater or equal than the total debt of the reserve'; + + string constant INV_SP_E = + 'INV_SP_E: Sum of user supplied shares on a spoke for a given asset == spoke supplied shares (hub spoke added shares)'; + + string constant INV_SP_F = + 'INV_SP_F: Sum of user supplied assets on a spoke for a given asset == spoke supplied assets (hub spoke added assets)'; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // RISK // + /////////////////////////////////////////////////////////////////////////////////////////////// + + string constant INV_SP_D = 'INV_SP_D: Users without collateral also have no debt.'; + + string constant INV_SP_H = + 'INV_SP_H: User dynamicConfigKey must never exceed the reserve dynamicConfigKey (no future config reference)'; + + string constant INV_SP_I = + 'INV_SP_I: User cannot have premium shares/offset without drawn shares (premium debt is always repaid first, and can only be created when drawn shares exist)'; +} diff --git a/invariants/shared/mocks/MockPriceFeedSimulator.sol b/invariants/shared/mocks/MockPriceFeedSimulator.sol new file mode 100644 index 000000000..10dda6073 --- /dev/null +++ b/invariants/shared/mocks/MockPriceFeedSimulator.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import {AggregatorV3Interface} from 'src/dependencies/chainlink/AggregatorV3Interface.sol'; + +contract MockPriceFeedSimulator is AggregatorV3Interface { + uint8 public immutable override decimals; + string public override description; + + int256 private _price; + + error OperationNotSupported(); + + constructor(uint8 decimals_, string memory description_, uint256 price_) { + decimals = decimals_; + description = description_; + _price = int256(price_); + } + + function version() external pure override returns (uint256) { + return 1; + } + + function getRoundData( + uint80 + ) external pure override returns (uint80, int256, uint256, uint256, uint80) { + revert OperationNotSupported(); + } + + function latestRoundData() + external + view + virtual + override + returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) + { + roundId = uint80(block.timestamp); + answer = _price; + startedAt = block.timestamp; + updatedAt = block.timestamp; + answeredInRound = roundId; + } + + function setPrice(int256 price) external { + _price = price; + } +} diff --git a/invariants/shared/remote/DOCKERFILE b/invariants/shared/remote/DOCKERFILE new file mode 100644 index 000000000..00a2c8c32 --- /dev/null +++ b/invariants/shared/remote/DOCKERFILE @@ -0,0 +1,17 @@ +#################### +## FOUNDRY INSTALL +#################### +FROM ghcr.io/foundry-rs/foundry:v0.3.0 AS builder-foundry + +RUN forge --version + +################## +## RUNNER +################## +FROM trailofbits/echidna:latest AS run + +RUN apt-get update && apt-get install -y make && rm -rf /var/lib/apt/lists/* + +COPY --from=builder-foundry /usr/local/bin/forge /usr/local/bin/forge + +WORKDIR /app diff --git a/invariants/shared/utils/Actor.sol b/invariants/shared/utils/Actor.sol new file mode 100644 index 000000000..0877ddea0 --- /dev/null +++ b/invariants/shared/utils/Actor.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +// Interfaces +import {SafeERC20, IERC20} from 'src/dependencies/openzeppelin/SafeERC20.sol'; + +/// @notice Proxy contract for invariant suite actors to avoid aTester calling contracts +contract Actor { + using SafeERC20 for IERC20; + + /// @dev Constructor approves the maximum amount of all tokens to all protocol contracts to avoid needing to approve in handlers + constructor(address[] memory tokens, address[] memory contracts) payable { + for (uint256 i = 0; i < tokens.length; ++i) { + for (uint256 j = 0; j < contracts.length; ++j) { + IERC20(tokens[i]).forceApprove(contracts[j], type(uint256).max); + } + } + } + + /// @notice Helper function to proxy a call to a target contract, used to avoid Tester calling contracts + function proxy(address target, bytes memory callData) public returns (bool, bytes memory) { + (bool ok, bytes memory ret) = address(target).call(callData); + _handleAssertionError(ok, ret); + return (ok, ret); + } + + /// @notice Helper function to proxy a call and value to a target contract, used to avoid Tester calling contracts + function proxy( + address target, + bytes memory callData, + uint256 value + ) public payable returns (bool, bytes memory) { + (bool ok, bytes memory ret) = address(target).call{value: value}(callData); + _handleAssertionError(ok, ret); + return (ok, ret); + } + + /// @notice Checks if a call failed due to an assertion error and propagates the error if found. + /// @param ok Indicates whether the call was successful. + /// @param ret The data returned from the call. + function _handleAssertionError(bool ok, bytes memory ret) internal pure { + if (!ok && ret.length == 36) { + bytes4 selector; + uint256 code; + assembly ('memory-safe') { + selector := mload(add(ret, 0x20)) + code := mload(add(ret, 0x24)) + } + + if (selector == bytes4(0x4e487b71) && code == 1) { + assert(false); + } + } + } + + receive() external payable {} +} diff --git a/invariants/shared/utils/ActorsUtils.sol b/invariants/shared/utils/ActorsUtils.sol new file mode 100644 index 000000000..230c308a2 --- /dev/null +++ b/invariants/shared/utils/ActorsUtils.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +// Test Contracts +import {Actor} from './Actor.sol'; +import {TestnetERC20} from 'tests/mocks/TestnetERC20.sol'; + +library ActorsUtils { + uint256 internal constant INITIAL_ETH_BALANCE = 1e26; + uint256 constant INITIAL_BALANCE = 1e12; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // ACTORS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Deploy protocol actors and initialize their balances + function setUpActors( + address[] memory addresses, + address[] memory tokens, + address[] memory contracts + ) internal returns (address[] memory actorAddresses) { + actorAddresses = new address[](addresses.length); + + // Initialize the three actors of the fuzzers + for (uint256 i; i < addresses.length; i++) { + // Deploy actor proxies and approve system contracts + address actor = setUpActor(tokens, contracts); + + // Mint initial balances to actors + for (uint256 j = 0; j < tokens.length; j++) { + TestnetERC20 token = TestnetERC20(tokens[j]); + uint256 decimals = token.decimals(); + token.mint(actor, INITIAL_BALANCE * 10 ** decimals); + } + actorAddresses[i] = actor; + } + } + + /// @notice Deploy an actor proxy contract + /// @param tokens Array of token addresses + /// @param contracts Array of contract addresses to aprove tokens to + /// @return Address of the deployed actor + function setUpActor( + address[] memory tokens, + address[] memory contracts + ) internal returns (address) { + Actor actor = new Actor(tokens, contracts); + (bool ok, ) = address(actor).call{value: INITIAL_ETH_BALANCE}(''); + assert(ok); + return address(actor); + } +} diff --git a/invariants/shared/utils/CREATE3.sol b/invariants/shared/utils/CREATE3.sol new file mode 100644 index 000000000..281a36c45 --- /dev/null +++ b/invariants/shared/utils/CREATE3.sol @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.4; + +/// @notice Deterministic deployments agnostic to the initialization code. +/// @author Solady (https://github.com/vectorized/solady/blob/main/src/utils/CREATE3.sol) +/// @author Modified from Solmate (https://github.com/transmissions11/solmate/blob/main/src/utils/CREATE3.sol) +/// @author Modified from 0xSequence (https://github.com/0xSequence/create3/blob/master/contracts/Create3.sol) +library CREATE3 { + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* CUSTOM ERRORS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /// @dev Unable to deploy the contract. + error DeploymentFailed(); + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* BYTECODE CONSTANTS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /** + * -------------------------------------------------------------------+ + * Opcode | Mnemonic | Stack | Memory | + * -------------------------------------------------------------------| + * 36 | CALLDATASIZE | cds | | + * 3d | RETURNDATASIZE | 0 cds | | + * 3d | RETURNDATASIZE | 0 0 cds | | + * 37 | CALLDATACOPY | | [0..cds): calldata | + * 36 | CALLDATASIZE | cds | [0..cds): calldata | + * 3d | RETURNDATASIZE | 0 cds | [0..cds): calldata | + * 34 | CALLVALUE | value 0 cds | [0..cds): calldata | + * f0 | CREATE | newContract | [0..cds): calldata | + * -------------------------------------------------------------------| + * Opcode | Mnemonic | Stack | Memory | + * -------------------------------------------------------------------| + * 67 bytecode | PUSH8 bytecode | bytecode | | + * 3d | RETURNDATASIZE | 0 bytecode | | + * 52 | MSTORE | | [0..8): bytecode | + * 60 0x08 | PUSH1 0x08 | 0x08 | [0..8): bytecode | + * 60 0x18 | PUSH1 0x18 | 0x18 0x08 | [0..8): bytecode | + * f3 | RETURN | | [0..8): bytecode | + * -------------------------------------------------------------------+ + */ + + /// @dev The proxy initialization code. + uint256 private constant _PROXY_INITCODE = 0x67363d3d37363d34f03d5260086018f3; + + /// @dev Hash of the `_PROXY_INITCODE`. + /// Equivalent to `keccak256(abi.encodePacked(hex"67363d3d37363d34f03d5260086018f3"))`. + bytes32 internal constant PROXY_INITCODE_HASH = + 0x21c35dbe1b344a2488cf3321d6ce542f8e9f305544ff09e4993a62319a497c1f; + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* CREATE3 OPERATIONS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /// @dev Deploys `initCode` deterministically with a `salt`. + /// Returns the deterministic address of the deployed contract, + /// which solely depends on `salt`. + function deployDeterministic( + bytes memory initCode, + bytes32 salt + ) internal returns (address deployed) { + deployed = deployDeterministic(0, initCode, salt); + } + + /// @dev Deploys `initCode` deterministically with a `salt`. + /// The deployed contract is funded with `value` (in wei) ETH. + /// Returns the deterministic address of the deployed contract, + /// which solely depends on `salt`. + function deployDeterministic( + uint256 value, + bytes memory initCode, + bytes32 salt + ) internal returns (address deployed) { + /// @solidity memory-safe-assembly + assembly { + mstore(0x00, _PROXY_INITCODE) // Store the `_PROXY_INITCODE`. + let proxy := create2(0, 0x10, 0x10, salt) + if iszero(proxy) { + mstore(0x00, 0x30116425) // `DeploymentFailed()`. + revert(0x1c, 0x04) + } + mstore(0x14, proxy) // Store the proxy's address. + // 0xd6 = 0xc0 (short RLP prefix) + 0x16 (length of: 0x94 ++ proxy ++ 0x01). + // 0x94 = 0x80 + 0x14 (0x14 = the length of an address, 20 bytes, in hex). + mstore(0x00, 0xd694) + mstore8(0x34, 0x01) // Nonce of the proxy contract (1). + deployed := keccak256(0x1e, 0x17) + if iszero( + mul( // The arguments of `mul` are evaluated last to first. + extcodesize(deployed), + call(gas(), proxy, value, add(initCode, 0x20), mload(initCode), 0x00, 0x00) + ) + ) { + mstore(0x00, 0x30116425) // `DeploymentFailed()`. + revert(0x1c, 0x04) + } + } + } + + /// @dev Returns the deterministic address for `salt`. + function predictDeterministicAddress(bytes32 salt) internal view returns (address deployed) { + deployed = predictDeterministicAddress(salt, address(this)); + } + + /// @dev Returns the deterministic address for `salt` with `deployer`. + function predictDeterministicAddress( + bytes32 salt, + address deployer + ) internal pure returns (address deployed) { + /// @solidity memory-safe-assembly + assembly { + let m := mload(0x40) // Cache the free memory pointer. + mstore(0x00, deployer) // Store `deployer`. + mstore8(0x0b, 0xff) // Store the prefix. + mstore(0x20, salt) // Store the salt. + mstore(0x40, PROXY_INITCODE_HASH) // Store the bytecode hash. + + mstore(0x14, keccak256(0x0b, 0x55)) // Store the proxy's address. + mstore(0x40, m) // Restore the free memory pointer. + // 0xd6 = 0xc0 (short RLP prefix) + 0x16 (length of: 0x94 ++ proxy ++ 0x01). + // 0x94 = 0x80 + 0x14 (0x14 = the length of an address, 20 bytes, in hex). + mstore(0x00, 0xd694) + mstore8(0x34, 0x01) // Nonce of the proxy contract (1). + deployed := keccak256(0x1e, 0x17) + } + } +} diff --git a/invariants/shared/utils/CommonHelpers.sol b/invariants/shared/utils/CommonHelpers.sol new file mode 100644 index 000000000..a3d7890d2 --- /dev/null +++ b/invariants/shared/utils/CommonHelpers.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import {Vm} from 'forge-std/Vm.sol'; +import {MockERC20} from 'tests/mocks/MockERC20.sol'; +import {IERC20} from 'src/dependencies/openzeppelin/IERC20.sol'; +import {Actor} from './Actor.sol'; + +Vm constant vm = Vm(address(uint160(uint256(keccak256('hevm cheat code'))))); + +contract CommonHelpers { + /////////////////////////////////////////////////////////////////////////////////////////////// + // HELPERS // + /////////////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Helper function to randomize a uint256 seed with a string salt + function _randomize(uint256 seed, string memory salt) internal pure returns (uint256) { + return uint256(keccak256(abi.encodePacked(seed, salt))); + } + + /// @notice Helper function to approve an amount of tokens to a spender, a proxy Actor + function _approve(address token, Actor actor, address spender, uint256 amount) internal { + (bool ok, bytes memory ret) = actor.proxy( + token, + abi.encodeCall(IERC20.approve, (spender, amount)) + ); + require(ok, string(ret)); + } + + /// @notice Helper function to safely approve an amount of tokens to a spender + function _approve(address token, address owner, address spender, uint256 amount) internal { + vm.prank(owner); + _safeApprove(token, spender, 0); + vm.prank(owner); + _safeApprove(token, spender, amount); + } + + /// @notice Helper function to safely approve an amount of tokens to a spender + /// @dev This function is used to revert on failed approvals + function _safeApprove(address token, address spender, uint256 amount) internal { + (bool ok, bytes memory ret) = token.call(abi.encodeCall(IERC20.approve, (spender, amount))); + assert(ok); + if (ret.length > 0) assert(abi.decode(ret, (bool))); + } + + /// @notice Helper function to mint an amount of tokens to an address + function _mint(address token, address receiver, uint256 amount) internal { + MockERC20(token).mint(receiver, amount); + } + + /// @notice Helper function to mint an amount of tokens to an address and approve them to a spender + /// @param token Address of the token to mint + /// @param owner Address of the new owner of the tokens + /// @param spender Address of the spender to approve the tokens to + /// @param amount Amount of tokens to mint and approve + function _mintAndApprove(address token, address owner, address spender, uint256 amount) internal { + _mint(token, owner, amount); + _approve(token, owner, spender, amount); + } + + /// @notice Best-effort mint — silently swallows overflow so the handler can proceed. + /// @dev Using a low-level call prevents the handler from reverting on mint failure + /// (e.g. totalSupply overflow), which would cause the fuzzer to discard the + /// entire call sequence. Spoke functions bound the passed amount to the user's + /// actual balance (e.g. repay(type(uint256).max) repays only the owed debt), + /// so even if minting the full amount overflows, the spoke will operate on + /// whatever balance exists. + function _tryMint(address token, address receiver, uint256 amount) internal { + (bool ok, ) = token.call(abi.encodeCall(MockERC20.mint, (receiver, amount))); + ok; // suppress compiler warning + } + + /// @notice Best-effort mint + approve for spoke handlers + function _tryMintAndApprove( + address token, + address owner, + address spender, + uint256 amount + ) internal { + _tryMint(token, owner, amount); + _approve(token, owner, spender, amount); + } +} diff --git a/invariants/shared/utils/DeployPermit2.sol b/invariants/shared/utils/DeployPermit2.sol new file mode 100644 index 000000000..5bb619a16 --- /dev/null +++ b/invariants/shared/utils/DeployPermit2.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +/// @notice Helper to deploy permit2 from precompiled bytecode +/// @dev Useful if testing externally against permit2 and want to avoid +/// recompiling entirely and requiring viaIR compilation +library DeployPermit2 { + /// @notice deploy permit2 + function deployPermit2() internal returns (address) { + bytes + memory bytecode = hex'60c0346100bb574660a052602081017f8cad95687ba82c2ce50e74f7b754645e5117c3a5bec8151c0726d5857980a86681527f9ac997416e8ff9d2ff6bebeb7149f65cdae5e32e2b90440b566bb3044041d36a60408301524660608301523060808301526080825260a082019180831060018060401b038411176100a557826040525190206080526123c090816100c1823960805181611b47015260a05181611b210152f35b634e487b7160e01b600052604160045260246000fd5b600080fdfe6040608081526004908136101561001557600080fd5b600090813560e01c80630d58b1db1461126c578063137c29fe146110755780632a2d80d114610db75780632b67b57014610bde57806330f28b7a14610ade5780633644e51514610a9d57806336c7851614610a285780633ff9dcb1146109a85780634fe02b441461093f57806365d9723c146107ac57806387517c451461067a578063927da105146105c3578063cc53287f146104a3578063edd9444b1461033a5763fe8ec1a7146100c657600080fd5b346103365760c07ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126103365767ffffffffffffffff833581811161033257610114903690860161164b565b60243582811161032e5761012b903690870161161a565b6101336114e6565b9160843585811161032a5761014b9036908a016115c1565b98909560a43590811161032657610164913691016115c1565b969095815190610173826113ff565b606b82527f5065726d697442617463685769746e6573735472616e7366657246726f6d285460208301527f6f6b656e5065726d697373696f6e735b5d207065726d69747465642c61646472838301527f657373207370656e6465722c75696e74323536206e6f6e63652c75696e74323560608301527f3620646561646c696e652c000000000000000000000000000000000000000000608083015282519a8b9181610222602085018096611f93565b918237018a8152039961025b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe09b8c8101835282611437565b5190209085515161026b81611ebb565b908a5b8181106102f95750506102f6999a6102ed9183516102a081610294602082018095611f66565b03848101835282611437565b519020602089810151858b015195519182019687526040820192909252336060820152608081019190915260a081019390935260643560c08401528260e081015b03908101835282611437565b51902093611cf7565b80f35b8061031161030b610321938c5161175e565b51612054565b61031b828661175e565b52611f0a565b61026e565b8880fd5b8780fd5b8480fd5b8380fd5b5080fd5b5091346103365760807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126103365767ffffffffffffffff9080358281116103325761038b903690830161164b565b60243583811161032e576103a2903690840161161a565b9390926103ad6114e6565b9160643590811161049f576103c4913691016115c1565b949093835151976103d489611ebb565b98885b81811061047d5750506102f697988151610425816103f9602082018095611f66565b037fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08101835282611437565b5190206020860151828701519083519260208401947ffcf35f5ac6a2c28868dc44c302166470266239195f02b0ee408334829333b7668652840152336060840152608083015260a082015260a081526102ed8161141b565b808b61031b8261049461030b61049a968d5161175e565b9261175e565b6103d7565b8680fd5b5082346105bf57602090817ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126103325780359067ffffffffffffffff821161032e576104f49136910161161a565b929091845b848110610504578580f35b8061051a610515600193888861196c565b61197c565b61052f84610529848a8a61196c565b0161197c565b3389528385528589209173ffffffffffffffffffffffffffffffffffffffff80911692838b528652868a20911690818a5285528589207fffffffffffffffffffffffff000000000000000000000000000000000000000081541690558551918252848201527f89b1add15eff56b3dfe299ad94e01f2b52fbcb80ae1a3baea6ae8c04cb2b98a4853392a2016104f9565b8280fd5b50346103365760607ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261033657610676816105ff6114a0565b936106086114c3565b6106106114e6565b73ffffffffffffffffffffffffffffffffffffffff968716835260016020908152848420928816845291825283832090871683528152919020549251938316845260a083901c65ffffffffffff169084015260d09190911c604083015281906060820190565b0390f35b50346103365760807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610336576106b26114a0565b906106bb6114c3565b916106c46114e6565b65ffffffffffff926064358481169081810361032a5779ffffffffffff0000000000000000000000000000000000000000947fda9fa7c1b00402c17d0161b249b1ab8bbec047c5a52207b9c112deffd817036b94338a5260016020527fffffffffffff0000000000000000000000000000000000000000000000000000858b209873ffffffffffffffffffffffffffffffffffffffff809416998a8d5260205283878d209b169a8b8d52602052868c209486156000146107a457504216925b8454921697889360a01b16911617179055815193845260208401523392a480f35b905092610783565b5082346105bf5760607ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126105bf576107e56114a0565b906107ee6114c3565b9265ffffffffffff604435818116939084810361032a57338852602091600183528489209673ffffffffffffffffffffffffffffffffffffffff80911697888b528452858a20981697888a5283528489205460d01c93848711156109175761ffff9085840316116108f05750907f55eb90d810e1700b35a8e7e25395ff7f2b2259abd7415ca2284dfb1c246418f393929133895260018252838920878a528252838920888a5282528389209079ffffffffffffffffffffffffffffffffffffffffffffffffffff7fffffffffffff000000000000000000000000000000000000000000000000000083549260d01b16911617905582519485528401523392a480f35b84517f24d35a26000000000000000000000000000000000000000000000000000000008152fd5b5084517f756688fe000000000000000000000000000000000000000000000000000000008152fd5b503461033657807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610336578060209273ffffffffffffffffffffffffffffffffffffffff61098f6114a0565b1681528084528181206024358252845220549051908152f35b5082346105bf57817ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126105bf577f3704902f963766a4e561bbaab6e6cdc1b1dd12f6e9e99648da8843b3f46b918d90359160243533855284602052818520848652602052818520818154179055815193845260208401523392a280f35b8234610a9a5760807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610a9a57610a606114a0565b610a686114c3565b610a706114e6565b6064359173ffffffffffffffffffffffffffffffffffffffff8316830361032e576102f6936117a1565b80fd5b503461033657817ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261033657602090610ad7611b1e565b9051908152f35b508290346105bf576101007ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126105bf57610b1a3661152a565b90807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7c36011261033257610b4c611478565b9160e43567ffffffffffffffff8111610bda576102f694610b6f913691016115c1565b939092610b7c8351612054565b6020840151828501519083519260208401947f939c21a48a8dbe3a9a2404a1d46691e4d39f6583d6ec6b35714604c986d801068652840152336060840152608083015260a082015260a08152610bd18161141b565b51902091611c25565b8580fd5b509134610336576101007ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261033657610c186114a0565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdc360160c08112610332576080855191610c51836113e3565b1261033257845190610c6282611398565b73ffffffffffffffffffffffffffffffffffffffff91602435838116810361049f578152604435838116810361049f57602082015265ffffffffffff606435818116810361032a5788830152608435908116810361049f576060820152815260a435938285168503610bda576020820194855260c4359087830182815260e43567ffffffffffffffff811161032657610cfe90369084016115c1565b929093804211610d88575050918591610d786102f6999a610d7e95610d238851611fbe565b90898c511690519083519260208401947ff3841cd1ff0085026a6327b620b67997ce40f282c88a8e905a7a5626e310f3d086528401526060830152608082015260808152610d70816113ff565b519020611bd9565b916120c7565b519251169161199d565b602492508a51917fcd21db4f000000000000000000000000000000000000000000000000000000008352820152fd5b5091346103365760607ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc93818536011261033257610df36114a0565b9260249081359267ffffffffffffffff9788851161032a578590853603011261049f578051978589018981108282111761104a578252848301358181116103265785019036602383011215610326578382013591610e50836115ef565b90610e5d85519283611437565b838252602093878584019160071b83010191368311611046578801905b828210610fe9575050508a526044610e93868801611509565b96838c01978852013594838b0191868352604435908111610fe557610ebb90369087016115c1565b959096804211610fba575050508998995151610ed681611ebb565b908b5b818110610f9757505092889492610d7892610f6497958351610f02816103f98682018095611f66565b5190209073ffffffffffffffffffffffffffffffffffffffff9a8b8b51169151928551948501957faf1b0d30d2cab0380e68f0689007e3254993c596f2fdd0aaa7f4d04f794408638752850152830152608082015260808152610d70816113ff565b51169082515192845b848110610f78578580f35b80610f918585610f8b600195875161175e565b5161199d565b01610f6d565b80610311610fac8e9f9e93610fb2945161175e565b51611fbe565b9b9a9b610ed9565b8551917fcd21db4f000000000000000000000000000000000000000000000000000000008352820152fd5b8a80fd5b6080823603126110465785608091885161100281611398565b61100b85611509565b8152611018838601611509565b838201526110278a8601611607565b8a8201528d611037818701611607565b90820152815201910190610e7a565b8c80fd5b84896041867f4e487b7100000000000000000000000000000000000000000000000000000000835252fd5b5082346105bf576101407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126105bf576110b03661152a565b91807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7c360112610332576110e2611478565b67ffffffffffffffff93906101043585811161049f5761110590369086016115c1565b90936101243596871161032a57611125610bd1966102f6983691016115c1565b969095825190611134826113ff565b606482527f5065726d69745769746e6573735472616e7366657246726f6d28546f6b656e5060208301527f65726d697373696f6e73207065726d69747465642c6164647265737320737065848301527f6e6465722c75696e74323536206e6f6e63652c75696e7432353620646561646c60608301527f696e652c0000000000000000000000000000000000000000000000000000000060808301528351948591816111e3602085018096611f93565b918237018b8152039361121c7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe095868101835282611437565b5190209261122a8651612054565b6020878101518589015195519182019687526040820192909252336060820152608081019190915260a081019390935260e43560c08401528260e081016102e1565b5082346105bf576020807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261033257813567ffffffffffffffff92838211610bda5736602383011215610bda5781013592831161032e576024906007368386831b8401011161049f57865b8581106112e5578780f35b80821b83019060807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdc83360301126103265761139288876001946060835161132c81611398565b611368608461133c8d8601611509565b9485845261134c60448201611509565b809785015261135d60648201611509565b809885015201611509565b918291015273ffffffffffffffffffffffffffffffffffffffff80808093169516931691166117a1565b016112da565b6080810190811067ffffffffffffffff8211176113b457604052565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b6060810190811067ffffffffffffffff8211176113b457604052565b60a0810190811067ffffffffffffffff8211176113b457604052565b60c0810190811067ffffffffffffffff8211176113b457604052565b90601f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0910116810190811067ffffffffffffffff8211176113b457604052565b60c4359073ffffffffffffffffffffffffffffffffffffffff8216820361149b57565b600080fd5b6004359073ffffffffffffffffffffffffffffffffffffffff8216820361149b57565b6024359073ffffffffffffffffffffffffffffffffffffffff8216820361149b57565b6044359073ffffffffffffffffffffffffffffffffffffffff8216820361149b57565b359073ffffffffffffffffffffffffffffffffffffffff8216820361149b57565b7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc01906080821261149b576040805190611563826113e3565b8082941261149b57805181810181811067ffffffffffffffff8211176113b457825260043573ffffffffffffffffffffffffffffffffffffffff8116810361149b578152602435602082015282526044356020830152606435910152565b9181601f8401121561149b5782359167ffffffffffffffff831161149b576020838186019501011161149b57565b67ffffffffffffffff81116113b45760051b60200190565b359065ffffffffffff8216820361149b57565b9181601f8401121561149b5782359167ffffffffffffffff831161149b576020808501948460061b01011161149b57565b91909160608184031261149b576040805191611666836113e3565b8294813567ffffffffffffffff9081811161149b57830182601f8201121561149b578035611693816115ef565b926116a087519485611437565b818452602094858086019360061b8501019381851161149b579086899897969594939201925b8484106116e3575050505050855280820135908501520135910152565b90919293949596978483031261149b578851908982019082821085831117611730578a928992845261171487611509565b81528287013583820152815201930191908897969594936116c6565b602460007f4e487b710000000000000000000000000000000000000000000000000000000081526041600452fd5b80518210156117725760209160051b010190565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052603260045260246000fd5b92919273ffffffffffffffffffffffffffffffffffffffff604060008284168152600160205282828220961695868252602052818120338252602052209485549565ffffffffffff8760a01c16804211611884575082871696838803611812575b5050611810955016926118b5565b565b878484161160001461184f57602488604051907ff96fb0710000000000000000000000000000000000000000000000000000000082526004820152fd5b7fffffffffffffffffffffffff000000000000000000000000000000000000000084846118109a031691161790553880611802565b602490604051907fd81b2f2e0000000000000000000000000000000000000000000000000000000082526004820152fd5b9060006064926020958295604051947f23b872dd0000000000000000000000000000000000000000000000000000000086526004860152602485015260448401525af13d15601f3d116001600051141617161561190e57565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601460248201527f5452414e534645525f46524f4d5f4641494c45440000000000000000000000006044820152fd5b91908110156117725760061b0190565b3573ffffffffffffffffffffffffffffffffffffffff8116810361149b5790565b9065ffffffffffff908160608401511673ffffffffffffffffffffffffffffffffffffffff908185511694826020820151169280866040809401511695169560009187835260016020528383208984526020528383209916988983526020528282209184835460d01c03611af5579185611ace94927fc6a377bfc4eb120024a8ac08eef205be16b817020812c73223e81d1bdb9708ec98979694508715600014611ad35779ffffffffffff00000000000000000000000000000000000000009042165b60a01b167fffffffffffff00000000000000000000000000000000000000000000000000006001860160d01b1617179055519384938491604091949373ffffffffffffffffffffffffffffffffffffffff606085019616845265ffffffffffff809216602085015216910152565b0390a4565b5079ffffffffffff000000000000000000000000000000000000000087611a60565b600484517f756688fe000000000000000000000000000000000000000000000000000000008152fd5b467f000000000000000000000000000000000000000000000000000000000000000003611b69577f000000000000000000000000000000000000000000000000000000000000000090565b60405160208101907f8cad95687ba82c2ce50e74f7b754645e5117c3a5bec8151c0726d5857980a86682527f9ac997416e8ff9d2ff6bebeb7149f65cdae5e32e2b90440b566bb3044041d36a604082015246606082015230608082015260808152611bd3816113ff565b51902090565b611be1611b1e565b906040519060208201927f190100000000000000000000000000000000000000000000000000000000000084526022830152604282015260428152611bd381611398565b9192909360a435936040840151804211611cc65750602084510151808611611c955750918591610d78611c6594611c60602088015186611e47565b611bd9565b73ffffffffffffffffffffffffffffffffffffffff809151511692608435918216820361149b57611810936118b5565b602490604051907f3728b83d0000000000000000000000000000000000000000000000000000000082526004820152fd5b602490604051907fcd21db4f0000000000000000000000000000000000000000000000000000000082526004820152fd5b959093958051519560409283830151804211611e175750848803611dee57611d2e918691610d7860209b611c608d88015186611e47565b60005b868110611d42575050505050505050565b611d4d81835161175e565b5188611d5a83878a61196c565b01359089810151808311611dbe575091818888886001968596611d84575b50505050505001611d31565b611db395611dad9273ffffffffffffffffffffffffffffffffffffffff6105159351169561196c565b916118b5565b803888888883611d78565b6024908651907f3728b83d0000000000000000000000000000000000000000000000000000000082526004820152fd5b600484517fff633a38000000000000000000000000000000000000000000000000000000008152fd5b6024908551907fcd21db4f0000000000000000000000000000000000000000000000000000000082526004820152fd5b9073ffffffffffffffffffffffffffffffffffffffff600160ff83161b9216600052600060205260406000209060081c6000526020526040600020818154188091551615611e9157565b60046040517f756688fe000000000000000000000000000000000000000000000000000000008152fd5b90611ec5826115ef565b611ed26040519182611437565b8281527fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0611f0082946115ef565b0190602036910137565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8114611f375760010190565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b805160208092019160005b828110611f7f575050505090565b835185529381019392810192600101611f71565b9081519160005b838110611fab575050016000815290565b8060208092840101518185015201611f9a565b60405160208101917f65626cad6cb96493bf6f5ebea28756c966f023ab9e8a83a7101849d5573b3678835273ffffffffffffffffffffffffffffffffffffffff8082511660408401526020820151166060830152606065ffffffffffff9182604082015116608085015201511660a082015260a0815260c0810181811067ffffffffffffffff8211176113b45760405251902090565b6040516020808201927f618358ac3db8dc274f0cd8829da7e234bd48cd73c4a740aede1adec9846d06a1845273ffffffffffffffffffffffffffffffffffffffff81511660408401520151606082015260608152611bd381611398565b919082604091031261149b576020823592013590565b6000843b61222e5750604182036121ac576120e4828201826120b1565b939092604010156117725760209360009360ff6040608095013560f81c5b60405194855216868401526040830152606082015282805260015afa156121a05773ffffffffffffffffffffffffffffffffffffffff806000511691821561217657160361214c57565b60046040517f815e1d64000000000000000000000000000000000000000000000000000000008152fd5b60046040517f8baa579f000000000000000000000000000000000000000000000000000000008152fd5b6040513d6000823e3d90fd5b60408203612204576121c0918101906120b1565b91601b7f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff84169360ff1c019060ff8211611f375760209360009360ff608094612102565b60046040517f4be6321b000000000000000000000000000000000000000000000000000000008152fd5b929391601f928173ffffffffffffffffffffffffffffffffffffffff60646020957fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0604051988997889687947f1626ba7e000000000000000000000000000000000000000000000000000000009e8f8752600487015260406024870152816044870152868601378b85828601015201168101030192165afa9081156123a857829161232a575b507fffffffff000000000000000000000000000000000000000000000000000000009150160361230057565b60046040517fb0669cbc000000000000000000000000000000000000000000000000000000008152fd5b90506020813d82116123a0575b8161234460209383611437565b810103126103365751907fffffffff0000000000000000000000000000000000000000000000000000000082168203610a9a57507fffffffff0000000000000000000000000000000000000000000000000000000090386122d4565b3d9150612337565b6040513d84823e3d90fdfea164736f6c6343000811000a'; + + return deployFromBytecode(bytecode); + } + + /// @notice helper function to deploy bytecode + function deployFromBytecode(bytes memory bytecode) internal returns (address child) { + assembly { + child := create(0, add(bytecode, 0x20), mload(bytecode)) + } + } +} diff --git a/invariants/shared/utils/ErrorHandlers.sol b/invariants/shared/utils/ErrorHandlers.sol new file mode 100644 index 000000000..165e28744 --- /dev/null +++ b/invariants/shared/utils/ErrorHandlers.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +/// @title ErrorHandlers +/// @notice Library for handling errors in the test suite +library ErrorHandlers { + /// @dev Selector for Panic(uint256) as defined by Solidity + bytes4 internal constant _PANIC_SELECTOR = 0x4e487b71; + /// @dev Panic code for assertion failed (0x01) + uint256 internal constant _PANIC_ASSERTION_FAILED = 0x01; + + event AssertFail(string); + + /// @notice Checks if a call failed due to an assertion error and propagates the error if found. + /// @param success Indicates whether the call was successful. + /// @param returnData The data returned from the call. + function handleAssertionError( + bool success, + bytes memory returnData, + bool detectNonAssertionErrors, + string memory errorMessage + ) internal { + // Case 1: do nothing if success is true + if (success) return; + + // Case 2: detect Panic(0x01) "Assertion" errors + // Decode potential Panic(uint256) (selector + uint256 = 36 bytes) + if (returnData.length == 36) { + bytes4 selector; + uint256 code; + assembly { + selector := mload(add(returnData, 0x20)) + code := mload(add(returnData, 0x24)) + } + // Case 3: if Panic(0x01) "Assertion" -> assert(false), this propagates the assertion error to the Tester context + if (selector == _PANIC_SELECTOR && code == _PANIC_ASSERTION_FAILED) { + assert(false); + } + } + + // Case 3: detect non-assertion errors and assert with the error message + if (detectNonAssertionErrors) { + assertWithMsg(false, errorMessage); + } + } + + function assertWithMsg(bool b, string memory reason) internal { + if (!b) { + emit AssertFail(reason); + assert(false); + } + } +} diff --git a/invariants/shared/utils/PropertiesAsserts.sol b/invariants/shared/utils/PropertiesAsserts.sol new file mode 100644 index 000000000..c82d3f6a8 --- /dev/null +++ b/invariants/shared/utils/PropertiesAsserts.sol @@ -0,0 +1,554 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/// @notice PropertiesAsserts is a library that provides assertions for properties of Solidity contracts. +/// @dev Added more assertions to the original PropertiesAsserts library. +abstract contract PropertiesAsserts { + event LogUint256(string, uint256); + event LogAddress(string, address); + event LogString(string); + + event AssertFail(string); + event AssertEqFail(string); + event AssertNeqFail(string); + event AssertGeFail(string); + event AssertGtFail(string); + event AssertLeFail(string); + event AssertLtFail(string); + + function assertWithMsg(bool b, string memory reason) internal { + if (!b) { + emit AssertFail(reason); + assert(false); + } + } + + /// @notice asserts that a is equal to b. + function assertEq(uint256 a, uint256 b) internal pure { + if (a != b) { + assert(false); + } + } + + function assertEq(int256 a, int256 b) internal pure { + if (a != b) { + assert(false); + } + } + + //write the function below for address + function assertEq(address a, address b) internal pure { + if (a != b) { + assert(false); + } + } + + // now with a reason parameter + function assertEq(address a, address b, string memory reason) internal { + if (a != b) { + string memory aStr = PropertiesLibString.toString(a); + string memory bStr = PropertiesLibString.toString(b); + bytes memory assertMsg = abi.encodePacked( + 'Invalid: ', + aStr, + '!=', + bStr, + ', reason: ', + reason + ); + emit AssertEqFail(string(assertMsg)); + assert(false); + } + } + + /// @notice asserts that a is equal to b. Violations are logged using reason. + function assertEq(uint256 a, uint256 b, string memory reason) internal { + if (a != b) { + string memory aStr = PropertiesLibString.toString(a); + string memory bStr = PropertiesLibString.toString(b); + bytes memory assertMsg = abi.encodePacked( + 'Invalid: ', + aStr, + '!=', + bStr, + ', reason: ', + reason + ); + emit AssertEqFail(string(assertMsg)); + assert(false); + } + } + + /// @notice int256 version of assertEq + function assertEq(int256 a, int256 b, string memory reason) internal { + if (a != b) { + string memory aStr = PropertiesLibString.toString(a); + string memory bStr = PropertiesLibString.toString(b); + bytes memory assertMsg = abi.encodePacked( + 'Invalid: ', + aStr, + '!=', + bStr, + ', reason: ', + reason + ); + emit AssertEqFail(string(assertMsg)); + assert(false); + } + } + + /// @notice asserts that a is not equal to b. Violations are logged using reason. + function assertNeq(uint256 a, uint256 b, string memory reason) internal { + if (a == b) { + string memory aStr = PropertiesLibString.toString(a); + string memory bStr = PropertiesLibString.toString(b); + bytes memory assertMsg = abi.encodePacked( + 'Invalid: ', + aStr, + '==', + bStr, + ', reason: ', + reason + ); + emit AssertNeqFail(string(assertMsg)); + assert(false); + } + } + + /// @notice int256 version of assertNeq + function assertNeq(int256 a, int256 b, string memory reason) internal { + if (a == b) { + string memory aStr = PropertiesLibString.toString(a); + string memory bStr = PropertiesLibString.toString(b); + bytes memory assertMsg = abi.encodePacked( + 'Invalid: ', + aStr, + '==', + bStr, + ', reason: ', + reason + ); + emit AssertNeqFail(string(assertMsg)); + assert(false); + } + } + + /// @notice asserts that a is greater than or equal to b. Violations are logged using reason. + function assertGe(uint256 a, uint256 b, string memory reason) internal { + if (!(a >= b)) { + string memory aStr = PropertiesLibString.toString(a); + string memory bStr = PropertiesLibString.toString(b); + bytes memory assertMsg = abi.encodePacked( + 'Invalid: ', + aStr, + '<', + bStr, + ' failed, reason: ', + reason + ); + emit AssertGeFail(string(assertMsg)); + assert(false); + } + } + + /// @notice int256 version of assertGe + function assertGe(int256 a, int256 b, string memory reason) internal { + if (!(a >= b)) { + string memory aStr = PropertiesLibString.toString(a); + string memory bStr = PropertiesLibString.toString(b); + bytes memory assertMsg = abi.encodePacked( + 'Invalid: ', + aStr, + '<', + bStr, + ' failed, reason: ', + reason + ); + emit AssertGeFail(string(assertMsg)); + assert(false); + } + } + + /// @dev Asserts that `a * b >= x * y` with full 512-bit precision. + function assertFullMulGe( + uint256 a, + uint256 b, + uint256 x, + uint256 y, + string memory reason + ) internal { + if (!fullMulGte(a, b, x, y)) { + string memory aStr = PropertiesLibString.toString(a); + string memory bStr = PropertiesLibString.toString(b); + string memory xStr = PropertiesLibString.toString(x); + string memory yStr = PropertiesLibString.toString(y); + + bytes memory assertMsg = abi.encodePacked( + 'Invalid: ', + aStr, + '*', + bStr, + ' < ', + xStr, + '*', + yStr, + ' failed, reason: ', + reason + ); + + emit AssertGeFail(string(assertMsg)); + assert(false); + } + } + + /// @notice asserts that a is greater than b. Violations are logged using reason. + function assertGt(uint256 a, uint256 b, string memory reason) internal { + if (!(a > b)) { + string memory aStr = PropertiesLibString.toString(a); + string memory bStr = PropertiesLibString.toString(b); + bytes memory assertMsg = abi.encodePacked( + 'Invalid: ', + aStr, + '<=', + bStr, + ' failed, reason: ', + reason + ); + emit AssertGtFail(string(assertMsg)); + assert(false); + } + } + + /// @notice int256 version of assertGt + function assertGt(int256 a, int256 b, string memory reason) internal { + if (!(a > b)) { + string memory aStr = PropertiesLibString.toString(a); + string memory bStr = PropertiesLibString.toString(b); + bytes memory assertMsg = abi.encodePacked( + 'Invalid: ', + aStr, + '<=', + bStr, + ' failed, reason: ', + reason + ); + emit AssertGtFail(string(assertMsg)); + assert(false); + } + } + + /// @notice asserts that a is less than or equal to b. Violations are logged using reason. + function assertLe(uint256 a, uint256 b, string memory reason) internal { + if (!(a <= b)) { + string memory aStr = PropertiesLibString.toString(a); + string memory bStr = PropertiesLibString.toString(b); + bytes memory assertMsg = abi.encodePacked( + 'Invalid: ', + aStr, + '>', + bStr, + ' failed, reason: ', + reason + ); + emit AssertLeFail(string(assertMsg)); + assert(false); + } + } + + /// @notice int256 version of assertLe + function assertLe(int256 a, int256 b, string memory reason) internal { + if (!(a <= b)) { + string memory aStr = PropertiesLibString.toString(a); + string memory bStr = PropertiesLibString.toString(b); + bytes memory assertMsg = abi.encodePacked( + 'Invalid: ', + aStr, + '>', + bStr, + ' failed, reason: ', + reason + ); + emit AssertLeFail(string(assertMsg)); + assert(false); + } + } + + /// @notice asserts that a is less than b. Violations are logged using reason. + function assertLt(uint256 a, uint256 b, string memory reason) internal { + if (!(a < b)) { + string memory aStr = PropertiesLibString.toString(a); + string memory bStr = PropertiesLibString.toString(b); + bytes memory assertMsg = abi.encodePacked( + 'Invalid: ', + aStr, + '>=', + bStr, + ' failed, reason: ', + reason + ); + emit AssertLtFail(string(assertMsg)); + assert(false); + } + } + + /// @notice int256 version of assertLt + function assertLt(int256 a, int256 b, string memory reason) internal { + if (!(a < b)) { + string memory aStr = PropertiesLibString.toString(a); + string memory bStr = PropertiesLibString.toString(b); + bytes memory assertMsg = abi.encodePacked( + 'Invalid: ', + aStr, + '>=', + bStr, + ' failed, reason: ', + reason + ); + emit AssertLtFail(string(assertMsg)); + assert(false); + } + } + + /// @notice Clamps value to be between low and high, both inclusive + function clampBetween(uint256 value, uint256 low, uint256 high) internal returns (uint256) { + if (value < low || value > high) { + uint256 ans = low + (value % (high - low + 1)); + string memory valueStr = PropertiesLibString.toString(value); + string memory ansStr = PropertiesLibString.toString(ans); + bytes memory message = abi.encodePacked('Clamping value ', valueStr, ' to ', ansStr); + emit LogString(string(message)); + return ans; + } + return value; + } + + /// @notice int256 version of clampBetween + function clampBetween(int256 value, int256 low, int256 high) internal returns (int256) { + if (value < low || value > high) { + int256 range = high - low + 1; + int256 clamped = (value - low) % (range); + if (clamped < 0) clamped += range; + int256 ans = low + clamped; + string memory valueStr = PropertiesLibString.toString(value); + string memory ansStr = PropertiesLibString.toString(ans); + bytes memory message = abi.encodePacked('Clamping value ', valueStr, ' to ', ansStr); + emit LogString(string(message)); + return ans; + } + return value; + } + + /// @notice clamps a to be less than b + function clampLt(uint256 a, uint256 b) internal returns (uint256) { + if (!(a < b)) { + assertNeq( + b, + 0, + 'clampLt cannot clamp value a to be less than zero. Check your inputs/assumptions.' + ); + uint256 value = a % b; + string memory aStr = PropertiesLibString.toString(a); + string memory valueStr = PropertiesLibString.toString(value); + bytes memory message = abi.encodePacked('Clamping value ', aStr, ' to ', valueStr); + emit LogString(string(message)); + return value; + } + return a; + } + + /// @notice int256 version of clampLt + function clampLt(int256 a, int256 b) internal returns (int256) { + if (!(a < b)) { + int256 value = b - 1; + string memory aStr = PropertiesLibString.toString(a); + string memory valueStr = PropertiesLibString.toString(value); + bytes memory message = abi.encodePacked('Clamping value ', aStr, ' to ', valueStr); + emit LogString(string(message)); + return value; + } + return a; + } + + /// @notice clamps a to be less than or equal to b + function clampLe(uint256 a, uint256 b) internal returns (uint256) { + if (!(a <= b)) { + uint256 value = a % (b + 1); + string memory aStr = PropertiesLibString.toString(a); + string memory valueStr = PropertiesLibString.toString(value); + bytes memory message = abi.encodePacked('Clamping value ', aStr, ' to ', valueStr); + emit LogString(string(message)); + return value; + } + return a; + } + + /// @notice int256 version of clampLe + function clampLe(int256 a, int256 b) internal returns (int256) { + if (!(a <= b)) { + int256 value = b; + string memory aStr = PropertiesLibString.toString(a); + string memory valueStr = PropertiesLibString.toString(value); + bytes memory message = abi.encodePacked('Clamping value ', aStr, ' to ', valueStr); + emit LogString(string(message)); + return value; + } + return a; + } + + /// @notice clamps a to be greater than b + function clampGt(uint256 a, uint256 b) internal returns (uint256) { + if (!(a > b)) { + assertNeq( + b, + type(uint256).max, + 'clampGt cannot clamp value a to be larger than uint256.max. Check your inputs/assumptions.' + ); + uint256 value = b + 1; + string memory aStr = PropertiesLibString.toString(a); + string memory valueStr = PropertiesLibString.toString(value); + bytes memory message = abi.encodePacked('Clamping value ', aStr, ' to ', valueStr); + emit LogString(string(message)); + return value; + } else { + return a; + } + } + + /// @notice int256 version of clampGt + function clampGt(int256 a, int256 b) internal returns (int256) { + if (!(a > b)) { + int256 value = b + 1; + string memory aStr = PropertiesLibString.toString(a); + string memory valueStr = PropertiesLibString.toString(value); + bytes memory message = abi.encodePacked('Clamping value ', aStr, ' to ', valueStr); + emit LogString(string(message)); + return value; + } else { + return a; + } + } + + /// @notice clamps a to be greater than or equal to b + function clampGe(uint256 a, uint256 b) internal returns (uint256) { + if (!(a > b)) { + uint256 value = b; + string memory aStr = PropertiesLibString.toString(a); + string memory valueStr = PropertiesLibString.toString(value); + bytes memory message = abi.encodePacked('Clamping value ', aStr, ' to ', valueStr); + emit LogString(string(message)); + return value; + } + return a; + } + + /// @notice int256 version of clampGe + function clampGe(int256 a, int256 b) internal returns (int256) { + if (!(a > b)) { + int256 value = b; + string memory aStr = PropertiesLibString.toString(a); + string memory valueStr = PropertiesLibString.toString(value); + bytes memory message = abi.encodePacked('Clamping value ', aStr, ' to ', valueStr); + emit LogString(string(message)); + return value; + } + return a; + } + + /// @dev Returns a * b >= x * y, with full precision. + function fullMulGte( + uint256 a, + uint256 b, + uint256 x, + uint256 y + ) internal pure returns (bool result) { + assembly { + let m := not(0) + let mm1 := mulmod(a, b, m) + let lo1 := mul(a, b) + let hi1 := sub(sub(mm1, lo1), lt(mm1, lo1)) + let mm2 := mulmod(x, y, m) + let lo2 := mul(x, y) + let hi2 := sub(sub(mm2, lo2), lt(mm2, lo2)) + result := or(gt(hi1, hi2), and(eq(hi1, hi2), iszero(lt(lo1, lo2)))) + } + } +} + +/// @notice Efficient library for creating string representations of integers. +/// @author Solmate (https://github.com/transmissions11/solmate/blob/main/src/utils/LibString.sol) +/// @author Modified from Solady (https://github.com/Vectorized/solady/blob/main/src/utils/LibString.sol) +/// @dev Name of the library is modified to prevent collisions with contract-under-test uses of LibString +library PropertiesLibString { + function toString(int256 value) internal pure returns (string memory str) { + uint256 absValue = value >= 0 ? uint256(value) : uint256(-value); + str = toString(absValue); + + if (value < 0) { + str = string(abi.encodePacked('-', str)); + } + } + + function toString(uint256 value) internal pure returns (string memory str) { + assembly { + // The maximum value of a uint256 contains 78 digits (1 byte per digit), but we allocate 160 bytes + // to keep the free memory pointer word aligned. We'll need 1 word for the length, 1 word for the + // trailing zeros padding, and 3 other words for a max of 78 digits. In total: 5 * 32 = 160 bytes. + let newFreeMemoryPointer := add(mload(0x40), 160) + + // Update the free memory pointer to avoid overriding our string. + mstore(0x40, newFreeMemoryPointer) + + // Assign str to the end of the zone of newly allocated memory. + str := sub(newFreeMemoryPointer, 32) + + // Clean the last word of memory it may not be overwritten. + mstore(str, 0) + + // Cache the end of the memory to calculate the length later. + let end := str + // We write the string from rightmost digit to leftmost digit. + // The following is essentially a do-while loop that also handles the zero case. + // prettier-ignore + for { let temp := value } 1 {} { + // Move the pointer 1 byte to the left. + str := sub(str, 1) + + // Write the character to the pointer. + // The ASCII index of the '0' character is 48. + mstore8(str, add(48, mod(temp, 10))) + + // Keep dividing temp until zero. + temp := div(temp, 10) + + // prettier-ignore + if iszero(temp) { break } + } + + // Compute and cache the final total length of the string. + let length := sub(end, str) + + // Move the pointer 32 bytes leftwards to make room for the length. + str := sub(str, 32) + + // Store the string's length at the start of memory allocated for our string. + mstore(str, length) + } + } + + function toString(address value) internal pure returns (string memory str) { + bytes memory s = new bytes(40); + for (uint256 i = 0; i < 20; i++) { + bytes1 b = bytes1(uint8(uint256(uint160(value)) / (2 ** (8 * (19 - i))))); + bytes1 hi = bytes1(uint8(b) / 16); + bytes1 lo = bytes1(uint8(b) - 16 * uint8(hi)); + s[2 * i] = char(hi); + s[2 * i + 1] = char(lo); + } + return string(s); + } + + function char(bytes1 b) internal pure returns (bytes1 c) { + if (uint8(b) < 10) return bytes1(uint8(b) + 0x30); + else return bytes1(uint8(b) + 0x57); + } +} diff --git a/invariants/shared/utils/PropertiesConstants.sol b/invariants/shared/utils/PropertiesConstants.sol new file mode 100644 index 000000000..15bd32b67 --- /dev/null +++ b/invariants/shared/utils/PropertiesConstants.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +abstract contract PropertiesConstants { + // Echidna constants + address constant USER1 = address(0x10000); + address constant USER2 = address(0x20000); + address constant USER3 = address(0x30000); + + // Suite constants + uint256 constant CHECK_ALL_RESERVES = type(uint256).max; + string constant GPOST_CHECK_FAILED = 'GPOST_CHECK_FAILED: checkPostConditions reverted'; + int256 constant PRICE_MIN = 0.0001e8; + int256 constant PRICE_MAX = 1e14; + + // Protocol constants + uint256 constant SPOKE_COUNT = 3; + uint256 constant PERCENTAGE_FACTOR = 1e4; + uint40 constant MAX_ALLOWED_SPOKE_CAP = type(uint40).max; + uint24 constant MAX_RISK_PREMIUM_THRESHOLD = type(uint24).max; + uint256 constant MAX_ALLOWED_COLLATERAL_RISK = 1000_00; + uint64 constant HEALTH_FACTOR_LIQUIDATION_THRESHOLD = 1e18; + uint256 constant MAX_TARGET_HEALTH_FACTOR = 2e18; + + // Interest rate 1 data + uint16 constant OPTIMAL_USAGE_RATIO_IR1 = 85_00; // 85.00% + uint16 constant BASE_VARIABLE_BORROW_RATE_IR1 = 1_00; // 1.00% + uint16 constant VARIABLE_RATE_SLOPE_1_IR1 = 4_00; // 4.00% + uint16 constant VARIABLE_RATE_SLOPE_2_IR1 = 55_00; // 55.00% + + // Interest rate 2 data + uint16 constant OPTIMAL_USAGE_RATIO_IR2 = 65_00; // 65.00% + uint16 constant BASE_VARIABLE_BORROW_RATE_IR2 = 2_00; // 2.00% + uint16 constant VARIABLE_RATE_SLOPE_1_IR2 = 7_00; // 7.00% + uint16 constant VARIABLE_RATE_SLOPE_2_IR2 = 75_00; // 75.00% + + // Spoke 1 liquidation config + uint128 constant TARGET_HEALTH_FACTOR_SPOKE1 = 1.05e18; + + // Spoke 2 liquidation config + uint128 constant TARGET_HEALTH_FACTOR_SPOKE2 = 1.02e18; +} diff --git a/invariants/shared/utils/StdAsserts.sol b/invariants/shared/utils/StdAsserts.sol new file mode 100644 index 000000000..ab9912c01 --- /dev/null +++ b/invariants/shared/utils/StdAsserts.sol @@ -0,0 +1,490 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.6.2 <0.9.0; + +import {PropertiesAsserts} from './PropertiesAsserts.sol'; +import {stdMath} from 'forge-std/StdMath.sol'; + +/// @notice Standardized assertions for use in Invariant tests, inherits PropertiesAsserts +/// @dev Adapted from forge to work with echidna & medusa +abstract contract StdAsserts is PropertiesAsserts { + event log(string); + event logs(bytes); + + event log_address(address); + event log_bytes32(bytes32); + event log_int(int256); + event log_uint(uint256); + event log_bytes(bytes); + event log_string(string); + + event log_named_address(string key, address val); + event log_named_bytes32(string key, bytes32 val); + event log_named_decimal_int(string key, int256 val, uint256 decimals); + event log_named_decimal_uint(string key, uint256 val, uint256 decimals); + event log_named_int(string key, int256 val); + event log_named_uint(string key, uint256 val); + event log_named_bytes(string key, bytes val); + event log_named_string(string key, string val); + event log_array(uint256[] val); + event log_array(int256[] val); + event log_array(address[] val); + event log_named_array(string key, uint256[] val); + event log_named_array(string key, int256[] val); + event log_named_array(string key, address[] val); + + function fail(string memory err) internal virtual { + emit log_named_string('Error', err); + fail(); + } + + function fail() internal virtual { + assert(false); + } + + function assertTrue(bool condition) internal { + if (!condition) { + emit log('Error: Assertion Failed'); + fail(); + } + } + + function assertTrue(bool condition, string memory err) internal { + if (!condition) { + emit log_named_string('Error', err); + assertTrue(condition); + } + } + + function assertFalse(bool data) internal virtual { + assertTrue(!data); + } + + function assertFalse(bool data, string memory err) internal virtual { + assertTrue(!data, err); + } + + function checkEq0(bytes memory a, bytes memory b) internal pure returns (bool ok) { + ok = true; + if (a.length == b.length) { + for (uint256 i = 0; i < a.length; i++) { + if (a[i] != b[i]) { + ok = false; + } + } + } else { + ok = false; + } + } + + function assertEq0(bytes memory a, bytes memory b) internal { + if (!checkEq0(a, b)) { + emit log('Error: a == b not satisfied [bytes]'); + emit log_named_bytes(' Expected', a); + emit log_named_bytes(' Actual', b); + fail(); + } + } + + function assertEq0(bytes memory a, bytes memory b, string memory err) internal { + if (!checkEq0(a, b)) { + emit log_named_string('Error', err); + assertEq0(a, b); + } + } + + function assertEq(bool a, bool b) internal virtual { + if (a != b) { + emit log('Error: a == b not satisfied [bool]'); + emit log_named_string(' Left', a ? 'true' : 'false'); + emit log_named_string(' Right', b ? 'true' : 'false'); + fail(); + } + } + + function assertEq(bool a, bool b, string memory err) internal virtual { + if (a != b) { + emit log_named_string('Error', err); + assertEq(a, b); + } + } + + function assertEq(bytes memory a, bytes memory b) internal virtual { + assertEq0(a, b); + } + + function assertEq(bytes memory a, bytes memory b, string memory err) internal virtual { + assertEq0(a, b, err); + } + + function assertEq(uint256[] memory a, uint256[] memory b) internal virtual { + if (keccak256(abi.encode(a)) != keccak256(abi.encode(b))) { + emit log('Error: a == b not satisfied [uint[]]'); + emit log_named_array(' Left', a); + emit log_named_array(' Right', b); + fail(); + } + } + + function assertEq(int256[] memory a, int256[] memory b) internal virtual { + if (keccak256(abi.encode(a)) != keccak256(abi.encode(b))) { + emit log('Error: a == b not satisfied [int[]]'); + emit log_named_array(' Left', a); + emit log_named_array(' Right', b); + fail(); + } + } + + function assertEq(address[] memory a, address[] memory b) internal virtual { + if (keccak256(abi.encode(a)) != keccak256(abi.encode(b))) { + emit log('Error: a == b not satisfied [address[]]'); + emit log_named_array(' Left', a); + emit log_named_array(' Right', b); + fail(); + } + } + + function assertEq(uint256[] memory a, uint256[] memory b, string memory err) internal virtual { + if (keccak256(abi.encode(a)) != keccak256(abi.encode(b))) { + emit log_named_string('Error', err); + assertEq(a, b); + } + } + + function assertEq(int256[] memory a, int256[] memory b, string memory err) internal virtual { + if (keccak256(abi.encode(a)) != keccak256(abi.encode(b))) { + emit log_named_string('Error', err); + assertEq(a, b); + } + } + + function assertEq(address[] memory a, address[] memory b, string memory err) internal virtual { + if (keccak256(abi.encode(a)) != keccak256(abi.encode(b))) { + emit log_named_string('Error', err); + assertEq(a, b); + } + } + + // Legacy helper + function assertEqUint(uint256 a, uint256 b) internal virtual { + assertEq(uint256(a), uint256(b)); + } + + function assertApproxEqAbs(uint256 a, uint256 b, uint256 maxDelta) internal virtual { + uint256 delta = stdMath.delta(a, b); + + if (delta > maxDelta) { + emit log('Error: a ~= b not satisfied [uint]'); + emit log_named_uint(' Left', a); + emit log_named_uint(' Right', b); + emit log_named_uint(' Max Delta', maxDelta); + emit log_named_uint(' Delta', delta); + fail(); + } + } + + function assertApproxEqAbs( + uint256 a, + uint256 b, + uint256 maxDelta, + string memory err + ) internal virtual { + uint256 delta = stdMath.delta(a, b); + + if (delta > maxDelta) { + emit log_named_string('Error', err); + assertApproxEqAbs(a, b, maxDelta); + } + } + + function assertApproxEqAbsDecimal( + uint256 a, + uint256 b, + uint256 maxDelta, + uint256 decimals + ) internal virtual { + uint256 delta = stdMath.delta(a, b); + + if (delta > maxDelta) { + emit log('Error: a ~= b not satisfied [uint]'); + emit log_named_decimal_uint(' Left', a, decimals); + emit log_named_decimal_uint(' Right', b, decimals); + emit log_named_decimal_uint(' Max Delta', maxDelta, decimals); + emit log_named_decimal_uint(' Delta', delta, decimals); + fail(); + } + } + + function assertApproxEqAbsDecimal( + uint256 a, + uint256 b, + uint256 maxDelta, + uint256 decimals, + string memory err + ) internal virtual { + uint256 delta = stdMath.delta(a, b); + + if (delta > maxDelta) { + emit log_named_string('Error', err); + assertApproxEqAbsDecimal(a, b, maxDelta, decimals); + } + } + + function assertApproxEqAbs(int256 a, int256 b, uint256 maxDelta) internal virtual { + uint256 delta = stdMath.delta(a, b); + + if (delta > maxDelta) { + emit log('Error: a ~= b not satisfied [int]'); + emit log_named_int(' Left', a); + emit log_named_int(' Right', b); + emit log_named_uint(' Max Delta', maxDelta); + emit log_named_uint(' Delta', delta); + fail(); + } + } + + function assertApproxEqAbs( + int256 a, + int256 b, + uint256 maxDelta, + string memory err + ) internal virtual { + uint256 delta = stdMath.delta(a, b); + + if (delta > maxDelta) { + emit log_named_string('Error', err); + assertApproxEqAbs(a, b, maxDelta); + } + } + + function assertApproxEqAbsDecimal( + int256 a, + int256 b, + uint256 maxDelta, + uint256 decimals + ) internal virtual { + uint256 delta = stdMath.delta(a, b); + + if (delta > maxDelta) { + emit log('Error: a ~= b not satisfied [int]'); + emit log_named_decimal_int(' Left', a, decimals); + emit log_named_decimal_int(' Right', b, decimals); + emit log_named_decimal_uint(' Max Delta', maxDelta, decimals); + emit log_named_decimal_uint(' Delta', delta, decimals); + fail(); + } + } + + function assertApproxEqAbsDecimal( + int256 a, + int256 b, + uint256 maxDelta, + uint256 decimals, + string memory err + ) internal virtual { + uint256 delta = stdMath.delta(a, b); + + if (delta > maxDelta) { + emit log_named_string('Error', err); + assertApproxEqAbsDecimal(a, b, maxDelta, decimals); + } + } + + function assertApproxEqRel( + uint256 a, + uint256 b, + uint256 maxPercentDelta // An 18 decimal fixed point number, where 1e18 == 100% + ) internal virtual { + if (b == 0) return assertEq(a, b); // If the left is 0, right must be too. + + uint256 percentDelta = stdMath.percentDelta(a, b); + + if (percentDelta > maxPercentDelta) { + emit log('Error: a ~= b not satisfied [uint]'); + emit log_named_uint(' Left', a); + emit log_named_uint(' Right', b); + emit log_named_decimal_uint(' Max % Delta', maxPercentDelta * 100, 18); + emit log_named_decimal_uint(' % Delta', percentDelta * 100, 18); + fail(); + } + } + + function assertApproxEqRel( + uint256 a, + uint256 b, + uint256 maxPercentDelta, // An 18 decimal fixed point number, where 1e18 == 100% + string memory err + ) internal virtual { + if (b == 0) return assertEq(a, b, err); // If the left is 0, right must be too. + + uint256 percentDelta = stdMath.percentDelta(a, b); + + if (percentDelta > maxPercentDelta) { + emit log_named_string('Error', err); + assertApproxEqRel(a, b, maxPercentDelta); + } + } + + function assertApproxEqRelDecimal( + uint256 a, + uint256 b, + uint256 maxPercentDelta, // An 18 decimal fixed point number, where 1e18 == 100% + uint256 decimals + ) internal virtual { + if (b == 0) return assertEq(a, b); // If the left is 0, right must be too. + + uint256 percentDelta = stdMath.percentDelta(a, b); + + if (percentDelta > maxPercentDelta) { + emit log('Error: a ~= b not satisfied [uint]'); + emit log_named_decimal_uint(' Left', a, decimals); + emit log_named_decimal_uint(' Right', b, decimals); + emit log_named_decimal_uint(' Max % Delta', maxPercentDelta * 100, 18); + emit log_named_decimal_uint(' % Delta', percentDelta * 100, 18); + fail(); + } + } + + function assertApproxEqRelDecimal( + uint256 a, + uint256 b, + uint256 maxPercentDelta, // An 18 decimal fixed point number, where 1e18 == 100% + uint256 decimals, + string memory err + ) internal virtual { + if (b == 0) return assertEq(a, b, err); // If the left is 0, right must be too. + + uint256 percentDelta = stdMath.percentDelta(a, b); + + if (percentDelta > maxPercentDelta) { + emit log_named_string('Error', err); + assertApproxEqRelDecimal(a, b, maxPercentDelta, decimals); + } + } + + function assertApproxEqRel(int256 a, int256 b, uint256 maxPercentDelta) internal virtual { + if (b == 0) return assertEq(a, b); // If the left is 0, right must be too. + + uint256 percentDelta = stdMath.percentDelta(a, b); + + if (percentDelta > maxPercentDelta) { + emit log('Error: a ~= b not satisfied [int]'); + emit log_named_int(' Left', a); + emit log_named_int(' Right', b); + emit log_named_decimal_uint(' Max % Delta', maxPercentDelta * 100, 18); + emit log_named_decimal_uint(' % Delta', percentDelta * 100, 18); + fail(); + } + } + + function assertApproxEqRel( + int256 a, + int256 b, + uint256 maxPercentDelta, + string memory err + ) internal virtual { + if (b == 0) return assertEq(a, b, err); // If the left is 0, right must be too. + + uint256 percentDelta = stdMath.percentDelta(a, b); + + if (percentDelta > maxPercentDelta) { + emit log_named_string('Error', err); + assertApproxEqRel(a, b, maxPercentDelta); + } + } + + function assertApproxEqRelDecimal( + int256 a, + int256 b, + uint256 maxPercentDelta, + uint256 decimals + ) internal virtual { + if (b == 0) return assertEq(a, b); // If the left is 0, right must be too. + + uint256 percentDelta = stdMath.percentDelta(a, b); + + if (percentDelta > maxPercentDelta) { + emit log('Error: a ~= b not satisfied [int]'); + emit log_named_decimal_int(' Left', a, decimals); + emit log_named_decimal_int(' Right', b, decimals); + emit log_named_decimal_uint(' Max % Delta', maxPercentDelta * 100, 18); + emit log_named_decimal_uint(' % Delta', percentDelta * 100, 18); + fail(); + } + } + + function assertApproxEqRelDecimal( + int256 a, + int256 b, + uint256 maxPercentDelta, + uint256 decimals, + string memory err + ) internal virtual { + if (b == 0) return assertEq(a, b, err); // If the left is 0, right must be too. + + uint256 percentDelta = stdMath.percentDelta(a, b); + + if (percentDelta > maxPercentDelta) { + emit log_named_string('Error', err); + assertApproxEqRelDecimal(a, b, maxPercentDelta, decimals); + } + } + + function assertEqCall( + address target, + bytes memory callDataA, + bytes memory callDataB + ) internal virtual { + assertEqCall(target, callDataA, target, callDataB, true); + } + + function assertEqCall( + address targetA, + bytes memory callDataA, + address targetB, + bytes memory callDataB + ) internal virtual { + assertEqCall(targetA, callDataA, targetB, callDataB, true); + } + + function assertEqCall( + address target, + bytes memory callDataA, + bytes memory callDataB, + bool strictRevertData + ) internal virtual { + assertEqCall(target, callDataA, target, callDataB, strictRevertData); + } + + function assertEqCall( + address targetA, + bytes memory callDataA, + address targetB, + bytes memory callDataB, + bool strictRevertData + ) internal virtual { + (bool successA, bytes memory returnDataA) = address(targetA).call(callDataA); + (bool successB, bytes memory returnDataB) = address(targetB).call(callDataB); + + if (successA && successB) { + assertEq(returnDataA, returnDataB, 'Call return data does not match'); + } + + if (!successA && !successB && strictRevertData) { + assertEq(returnDataA, returnDataB, 'Call revert data does not match'); + } + + if (!successA && successB) { + emit log('Error: Calls were not equal'); + emit log_named_bytes(' Left call revert data', returnDataA); + emit log_named_bytes(' Right call return data', returnDataB); + fail(); + } + + if (successA && !successB) { + emit log('Error: Calls were not equal'); + emit log_named_bytes(' Left call return data', returnDataA); + emit log_named_bytes(' Right call revert data', returnDataB); + fail(); + } + } +} diff --git a/medusa.hub.json b/medusa.hub.json new file mode 100644 index 000000000..3b3b49ecd --- /dev/null +++ b/medusa.hub.json @@ -0,0 +1,76 @@ +{ + "fuzzing": { + "workers": 15, + "workerResetLimit": 50, + "timeout": 25000, + "testLimit": 0, + "callSequenceLength": 100, + "corpusDirectory": "invariants/hub-suite/_corpus/medusa", + "coverageEnabled": true, + "deploymentOrder": ["Tester"], + "targetContracts": ["Tester"], + "targetContractsBalances": [ + "0xffffffffffffffffffffffffffffffffffffffffffffffffffff" + ], + "predeployedContracts": {}, + "constructorArgs": {}, + "deployerAddress": "0x30000", + "senderAddresses": ["0x10000", "0x20000", "0x30000"], + "blockNumberDelayMax": 60480, + "blockTimestampDelayMax": 604800, + "blockGasLimit": 12500000000, + "transactionGasLimit": 1250000000, + "testing": { + "stopOnFailedTest": false, + "stopOnFailedContractMatching": false, + "stopOnNoTests": true, + "testAllContracts": false, + "traceAll": false, + "assertionTesting": { + "enabled": true, + "testViewMethods": true, + "assertionModes": { + "failOnCompilerInsertedPanic": false, + "failOnAssertion": true, + "failOnArithmeticUnderflow": false, + "failOnDivideByZero": false, + "failOnEnumTypeConversionOutOfBounds": false, + "failOnIncorrectStorageAccess": false, + "failOnPopEmptyArray": false, + "failOnOutOfBoundsArrayAccess": false, + "failOnAllocateTooMuchMemory": false, + "failOnCallUninitializedVariable": false + } + }, + "propertyTesting": { + "enabled": true, + "testPrefixes": ["fuzz_", "invariant_"] + }, + "optimizationTesting": { + "enabled": false, + "testPrefixes": ["optimize_"] + }, + "excludeFunctionSignatures": ["Tester.checkPostConditions()"] + }, + "chainConfig": { + "codeSizeCheckDisabled": true, + "cheatCodes": { + "cheatCodesEnabled": true, + "enableFFI": false + } + } + }, + "compilation": { + "platform": "crytic-compile", + "platformConfig": { + "target": "invariants/hub-suite/Tester.t.sol", + "solcVersion": "", + "exportDirectory": "", + "args": ["--solc-remaps", "forge-std/=../../../lib/forge-std/src/"] + } + }, + "logging": { + "level": "info", + "logDirectory": "" + } +} diff --git a/medusa.protocol.json b/medusa.protocol.json new file mode 100644 index 000000000..a84ddc77e --- /dev/null +++ b/medusa.protocol.json @@ -0,0 +1,82 @@ +{ + "fuzzing": { + "workers": 30, + "workerResetLimit": 50, + "timeout": 0, + "testLimit": 0, + "callSequenceLength": 100, + "corpusDirectory": "invariants/protocol-suite/_corpus/medusa", + "coverageEnabled": true, + "deploymentOrder": ["Tester"], + "targetContracts": ["Tester"], + "targetContractsBalances": [ + "0xffffffffffffffffffffffffffffffffffffffffffffffffffff" + ], + "predeployedContracts": { + "LiquidationLogic": "0xf01" + }, + "constructorArgs": {}, + "deployerAddress": "0x30000", + "senderAddresses": ["0x10000", "0x20000", "0x30000"], + "blockNumberDelayMax": 60480, + "blockTimestampDelayMax": 604800, + "blockGasLimit": 12500000000, + "transactionGasLimit": 1250000000, + "testing": { + "stopOnFailedTest": false, + "stopOnFailedContractMatching": false, + "stopOnNoTests": true, + "testAllContracts": false, + "traceAll": false, + "assertionTesting": { + "enabled": true, + "testViewMethods": true, + "assertionModes": { + "failOnCompilerInsertedPanic": false, + "failOnAssertion": true, + "failOnArithmeticUnderflow": false, + "failOnDivideByZero": false, + "failOnEnumTypeConversionOutOfBounds": false, + "failOnIncorrectStorageAccess": false, + "failOnPopEmptyArray": false, + "failOnOutOfBoundsArrayAccess": false, + "failOnAllocateTooMuchMemory": false, + "failOnCallUninitializedVariable": false + } + }, + "propertyTesting": { + "enabled": true, + "testPrefixes": ["fuzz_", "invariant_"] + }, + "optimizationTesting": { + "enabled": false, + "testPrefixes": ["optimize_"] + }, + "excludeFunctionSignatures": ["Tester.checkPostConditions()"] + }, + "chainConfig": { + "codeSizeCheckDisabled": true, + "cheatCodes": { + "cheatCodesEnabled": true, + "enableFFI": true + } + } + }, + "compilation": { + "platform": "crytic-compile", + "platformConfig": { + "target": "invariants/protocol-suite/Tester.t.sol", + "solcVersion": "", + "exportDirectory": "", + "args": [ + "--solc-remaps", + "forge-std/=../../../lib/forge-std/src/", + "--compile-libraries=(LiquidationLogic,0xf01)" + ] + } + }, + "logging": { + "level": "info", + "logDirectory": "" + } +}